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 'fix(sheets): chat keyboard capture + refactor shared chat wiring' (#164) from fix/sheets-chat-and-debt into main

scott 71e26c5d 2441931b

+307 -228
+15
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ### Fixed 11 + - Fix sheets chat input: keyboard handler no longer captures typing in AI chat sidebar (#233) 12 + 13 + ### Changed 14 + - Refactor duplicated AI chat wiring into shared `initChatWiring()`, removing ~230 lines of duplication (#234) 15 + - Add 11 tests for `initChatWiring` (config propagation, toggle, send/stop/clear, editor-type labels) 16 + 17 + ### Security 18 + - Fix XSS + review findings from AI chat PR #160 (#232) 19 + 10 20 ## [0.12.0] — 2026-03-24 11 21 12 22 ### Added 23 + - Add automated semantic versioning to CI pipeline (#182) 13 24 - AI chat panel on all editor types (docs + sheets) with shared module (#229) 14 25 - AI content actions: AI can insert, replace, and suggest changes in documents (#231) 15 26 - AI spreadsheet actions: AI can set cell values/formulas and clear ranges (#231) ··· 19 30 - Sheet context extraction: AI can read spreadsheet data as TSV 20 31 21 32 ### Changed 33 + - Expand AI chat test coverage: EditorType, DOM structure, streamChat, sendChat (#230) 34 + - Ship PR #147: docker-compose deployment, workspace/landing page (#210) 35 + - Ship PR #146: Matrix AI PWA - monitor CI and merge (#211) 22 36 - Moved ai-chat.ts from src/docs/ to src/lib/ (shared across editors) 23 37 - Replaced emoji icons with SVG in sheets toolbar (history, share buttons) 24 38 - System prompt adapts per editor type (writing assistant vs data assistant) ··· 30 44 - Add AI chat panel for docs with Aperture and OpenRouter integration (#215) 31 45 32 46 ### Fixed 47 + - Fix hiding single column also hides adjacent column (#223) 33 48 - Fix: express.static serves HTML without no-cache headers (#227) 34 49 - Add Clear-Site-Data header to bust Firefox cache + version bump (#226) 35 50 - Fix stale SW in regular Firefox — force update mechanism (#225)
+13 -117
src/docs/main.ts
··· 59 59 import { TableToolbarState } from './table-toolbar.js'; 60 60 import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; 61 61 import { 62 - createChatSidebar, createChatState, loadConfig, saveConfig, isConfigured, 62 + createChatSidebar, createChatState, loadConfig, isConfigured, 63 63 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 64 - autoResizeTextarea, renderMarkdown, MODEL_OPTIONS, appendActionCard, escapeHtml, 65 - type ChatMessage, type ChatConfig, 64 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 65 + type ChatMessage, 66 66 } from '../lib/ai-chat.js'; 67 67 import { splitResponse, isDocAction } from '../lib/ai-actions.js'; 68 68 import { executeDocAction } from './ai-doc-actions.js'; ··· 2356 2356 mainContent.appendChild(chatUI.container); 2357 2357 2358 2358 const chatState = createChatState(); 2359 - let chatConfig = loadConfig(); 2360 2359 2361 - // Populate settings from saved config 2362 - chatUI.endpointInput.value = chatConfig.endpoint; 2363 - 2364 - // Set model dropdown to match config (or custom) 2365 - const knownModel = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 2366 - if (knownModel) { 2367 - chatUI.modelSelect.value = chatConfig.model; 2368 - } else if (chatConfig.model) { 2369 - chatUI.modelSelect.value = '__custom'; 2370 - const customInput = chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement; 2371 - customInput.style.display = ''; 2372 - customInput.value = chatConfig.model; 2373 - } 2374 - 2375 - function updateModelBadge(): void { 2376 - const badge = chatUI.container.querySelector('#ai-model-badge') as HTMLElement; 2377 - const opt = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 2378 - badge.textContent = opt ? opt.label : chatConfig.model.split('/').pop() || ''; 2379 - } 2380 - updateModelBadge(); 2381 - 2382 - // Show settings on first open if not configured 2383 - let settingsShownOnce = false; 2384 - 2385 - function toggleChatPanel(): void { 2386 - const isOpen = chatUI.container.style.display !== 'none'; 2387 - if (isOpen) { 2388 - chatUI.container.style.display = 'none'; 2389 - $('btn-ai-chat').classList.remove('active'); 2390 - } else { 2391 - chatUI.container.style.display = ''; 2392 - $('btn-ai-chat').classList.add('active'); 2393 - if (!isConfigured(chatConfig) && !settingsShownOnce) { 2394 - chatUI.settingsPanel.style.display = ''; 2395 - settingsShownOnce = true; 2396 - } 2397 - chatUI.input.focus(); 2398 - } 2399 - } 2400 - 2401 - $('btn-ai-chat').addEventListener('click', toggleChatPanel); 2402 - chatUI.closeBtn.addEventListener('click', toggleChatPanel); 2403 - 2404 - // Keyboard shortcut: Cmd+Shift+L 2405 - document.addEventListener('keydown', (e) => { 2406 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'l') { 2407 - e.preventDefault(); 2408 - toggleChatPanel(); 2409 - } 2410 - }); 2411 - 2412 - // Settings toggle 2413 - chatUI.settingsBtn.addEventListener('click', () => { 2414 - const panel = chatUI.settingsPanel; 2415 - panel.style.display = panel.style.display === 'none' ? '' : 'none'; 2416 - }); 2417 - 2418 - // Save settings on change 2419 - function persistSettings(): void { 2420 - const model = chatUI.modelSelect.value === '__custom' 2421 - ? (chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement).value.trim() 2422 - : chatUI.modelSelect.value; 2423 - 2424 - chatConfig = { 2425 - endpoint: chatUI.endpointInput.value.trim(), 2426 - model: model || 'claude-sonnet-4-20250514', 2427 - maxTokens: chatConfig.maxTokens, 2428 - }; 2429 - saveConfig(chatConfig); 2430 - updateModelBadge(); 2431 - } 2432 - 2433 - chatUI.endpointInput.addEventListener('change', persistSettings); 2434 - chatUI.modelSelect.addEventListener('change', () => { 2435 - const customInput = chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement; 2436 - customInput.style.display = chatUI.modelSelect.value === '__custom' ? '' : 'none'; 2437 - persistSettings(); 2360 + const chatWiring = initChatWiring({ 2361 + chatUI, 2362 + chatState, 2363 + chatConfig: loadConfig(), 2364 + toggleBtn: $('btn-ai-chat'), 2365 + editorType: 'doc', 2366 + onSend: sendMessage, 2438 2367 }); 2439 - (chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement).addEventListener('change', persistSettings); 2440 - 2441 - // Auto-resize input 2442 - chatUI.input.addEventListener('input', () => autoResizeTextarea(chatUI.input)); 2443 2368 2444 2369 // Send message 2445 2370 async function sendMessage(): Promise<void> { 2446 2371 const text = chatUI.input.value.trim(); 2447 2372 if (!text || chatState.loading) return; 2448 2373 2449 - if (!isConfigured(chatConfig)) { 2374 + const cfg = chatWiring.getConfig(); 2375 + if (!isConfigured(cfg)) { 2450 2376 chatUI.settingsPanel.style.display = ''; 2451 2377 chatUI.endpointInput.focus(); 2452 2378 return; ··· 2484 2410 let fullText = ''; 2485 2411 2486 2412 await streamChat( 2487 - chatConfig, 2413 + cfg, 2488 2414 chatState.messages, 2489 2415 systemPrompt, 2490 2416 { ··· 2544 2470 chatUI.stopBtn.style.display = 'none'; 2545 2471 } 2546 2472 2547 - chatUI.sendBtn.addEventListener('click', sendMessage); 2548 - chatUI.input.addEventListener('keydown', (e) => { 2549 - if (e.key === 'Enter' && !e.shiftKey) { 2550 - e.preventDefault(); 2551 - sendMessage(); 2552 - } 2553 - }); 2554 - 2555 - // Stop generating 2556 - chatUI.stopBtn.addEventListener('click', () => { 2557 - chatState.abortController?.abort(); 2558 - chatState.loading = false; 2559 - chatUI.sendBtn.style.display = ''; 2560 - chatUI.stopBtn.style.display = 'none'; 2561 - }); 2562 - 2563 - // Clear chat 2564 - chatUI.clearBtn.addEventListener('click', () => { 2565 - chatState.messages = []; 2566 - chatState.error = null; 2567 - chatUI.messageList.innerHTML = ` 2568 - <div class="ai-chat-empty" id="ai-chat-empty"> 2569 - <div class="ai-chat-empty-icon"> 2570 - <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5h18a1.5 1.5 0 0 1 1.5 1.5v10.5a1.5 1.5 0 0 1-1.5 1.5H7.5L3 22.5V6a1.5 1.5 0 0 1 1.5-1.5z"/><circle cx="8.25" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="12" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="15.75" cy="11.25" r="0.75" fill="currentColor" stroke="none"/></svg> 2571 - </div> 2572 - <div class="ai-chat-empty-text">Ask anything about your document</div> 2573 - <div class="ai-chat-empty-hint">The AI can see your document content when context is enabled</div> 2574 - </div> 2575 - `; 2576 - });
+146
src/lib/ai-chat.ts
··· 610 610 list.scrollTop = list.scrollHeight; 611 611 return card; 612 612 } 613 + 614 + // ── Shared chat wiring ─────────────────────────────────────────────── 615 + 616 + export interface ChatWiringOptions { 617 + chatUI: ReturnType<typeof createChatSidebar>; 618 + chatState: ReturnType<typeof createChatState>; 619 + chatConfig: ChatConfig; 620 + toggleBtn: HTMLElement; 621 + editorType: EditorType; 622 + onSend: () => void; 623 + } 624 + 625 + /** 626 + * Wire up all the common AI chat panel listeners. 627 + * Returns a handle with `updateConfig` to keep the config reference in sync. 628 + */ 629 + export function initChatWiring(opts: ChatWiringOptions): { 630 + getConfig: () => ChatConfig; 631 + persistSettings: () => void; 632 + togglePanel: () => void; 633 + } { 634 + const { chatUI, chatState, toggleBtn, editorType } = opts; 635 + let chatConfig = opts.chatConfig; 636 + let settingsShownOnce = false; 637 + 638 + // Populate settings from saved config 639 + chatUI.endpointInput.value = chatConfig.endpoint; 640 + const knownModel = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 641 + if (knownModel) { 642 + chatUI.modelSelect.value = chatConfig.model; 643 + } else if (chatConfig.model) { 644 + chatUI.modelSelect.value = '__custom'; 645 + const customInput = chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement; 646 + customInput.style.display = ''; 647 + customInput.value = chatConfig.model; 648 + } 649 + 650 + // Model badge 651 + function updateModelBadge(): void { 652 + const badge = chatUI.container.querySelector('#ai-model-badge') as HTMLElement; 653 + const opt = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 654 + badge.textContent = opt ? opt.label : chatConfig.model.split('/').pop() || ''; 655 + } 656 + updateModelBadge(); 657 + 658 + // Toggle panel 659 + function togglePanel(): void { 660 + const isOpen = chatUI.container.style.display !== 'none'; 661 + if (isOpen) { 662 + chatUI.container.style.display = 'none'; 663 + toggleBtn.classList.remove('active'); 664 + } else { 665 + chatUI.container.style.display = ''; 666 + toggleBtn.classList.add('active'); 667 + if (!isConfigured(chatConfig) && !settingsShownOnce) { 668 + chatUI.settingsPanel.style.display = ''; 669 + settingsShownOnce = true; 670 + } 671 + chatUI.input.focus(); 672 + } 673 + } 674 + 675 + toggleBtn.addEventListener('click', togglePanel); 676 + chatUI.closeBtn.addEventListener('click', togglePanel); 677 + 678 + // Keyboard shortcut: Cmd+Shift+L 679 + document.addEventListener('keydown', (e) => { 680 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'l') { 681 + e.preventDefault(); 682 + togglePanel(); 683 + } 684 + }); 685 + 686 + // Settings toggle 687 + chatUI.settingsBtn.addEventListener('click', () => { 688 + const panel = chatUI.settingsPanel; 689 + panel.style.display = panel.style.display === 'none' ? '' : 'none'; 690 + }); 691 + 692 + // Persist settings 693 + function persistSettings(): void { 694 + const model = chatUI.modelSelect.value === '__custom' 695 + ? (chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement).value.trim() 696 + : chatUI.modelSelect.value; 697 + 698 + chatConfig = { 699 + endpoint: chatUI.endpointInput.value.trim(), 700 + model: model || 'claude-sonnet-4-20250514', 701 + maxTokens: chatConfig.maxTokens, 702 + }; 703 + opts.chatConfig = chatConfig; 704 + saveConfig(chatConfig); 705 + updateModelBadge(); 706 + } 707 + 708 + chatUI.endpointInput.addEventListener('change', persistSettings); 709 + chatUI.modelSelect.addEventListener('change', () => { 710 + const customInput = chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement; 711 + customInput.style.display = chatUI.modelSelect.value === '__custom' ? '' : 'none'; 712 + persistSettings(); 713 + }); 714 + (chatUI.container.querySelector('#ai-model-custom') as HTMLInputElement).addEventListener('change', persistSettings); 715 + 716 + // Auto-resize input 717 + chatUI.input.addEventListener('input', () => autoResizeTextarea(chatUI.input)); 718 + 719 + // Send 720 + chatUI.sendBtn.addEventListener('click', opts.onSend); 721 + chatUI.input.addEventListener('keydown', (e) => { 722 + if (e.key === 'Enter' && !e.shiftKey) { 723 + e.preventDefault(); 724 + opts.onSend(); 725 + } 726 + }); 727 + 728 + // Stop 729 + chatUI.stopBtn.addEventListener('click', () => { 730 + chatState.abortController?.abort(); 731 + chatState.loading = false; 732 + chatUI.sendBtn.style.display = ''; 733 + chatUI.stopBtn.style.display = 'none'; 734 + }); 735 + 736 + // Clear 737 + const label = editorType === 'sheet' ? 'spreadsheet' : 'document'; 738 + const contextWord = editorType === 'sheet' ? 'data' : 'content'; 739 + chatUI.clearBtn.addEventListener('click', () => { 740 + chatState.messages = []; 741 + chatState.error = null; 742 + chatUI.messageList.innerHTML = ` 743 + <div class="ai-chat-empty" id="ai-chat-empty"> 744 + <div class="ai-chat-empty-icon"> 745 + <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5h18a1.5 1.5 0 0 1 1.5 1.5v10.5a1.5 1.5 0 0 1-1.5 1.5H7.5L3 22.5V6a1.5 1.5 0 0 1 1.5-1.5z"/><circle cx="8.25" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="12" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="15.75" cy="11.25" r="0.75" fill="currentColor" stroke="none"/></svg> 746 + </div> 747 + <div class="ai-chat-empty-text">Ask anything about your ${label}</div> 748 + <div class="ai-chat-empty-hint">The AI can see your ${label} ${contextWord} when context is enabled</div> 749 + </div> 750 + `; 751 + }); 752 + 753 + return { 754 + getConfig: () => chatConfig, 755 + persistSettings, 756 + togglePanel, 757 + }; 758 + }
+14 -111
src/sheets/main.ts
··· 44 44 import { buildSheetsPrintHtml } from '../lib/print-layout.js'; 45 45 import type { PrintCell, PrintRow, SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 46 46 import { 47 - createChatSidebar, createChatState, loadConfig, saveConfig, isConfigured, 47 + createChatSidebar, createChatState, loadConfig, isConfigured, 48 48 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 49 - autoResizeTextarea, renderMarkdown, MODEL_OPTIONS, appendActionCard, escapeHtml, 49 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 50 50 } from '../lib/ai-chat.js'; 51 51 import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 52 52 import { executeSheetAction } from './ai-sheet-actions.js'; ··· 1523 1523 if (document.activeElement === document.getElementById('doc-title')) return; 1524 1524 // Skip if find-replace bar inputs are focused 1525 1525 if (document.activeElement && document.activeElement.closest('.sheets-find-bar')) return; 1526 + // Skip if AI chat sidebar inputs are focused 1527 + if (document.activeElement && document.activeElement.closest('.ai-chat-sidebar')) return; 1526 1528 1527 1529 if (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight') { 1528 1530 e.preventDefault(); ··· 5302 5304 document.getElementById('app').appendChild(chatUI.container); 5303 5305 5304 5306 const chatState = createChatState(); 5305 - let chatConfig = loadConfig(); 5306 5307 5307 - // Populate settings from saved config 5308 - chatUI.endpointInput.value = chatConfig.endpoint; 5309 - 5310 - // Set model dropdown to match config (or custom) 5311 - const knownModel = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 5312 - if (knownModel) { 5313 - chatUI.modelSelect.value = chatConfig.model; 5314 - } else if (chatConfig.model) { 5315 - chatUI.modelSelect.value = '__custom'; 5316 - const customInput = chatUI.container.querySelector('#ai-model-custom'); 5317 - customInput.style.display = ''; 5318 - customInput.value = chatConfig.model; 5319 - } 5320 - 5321 - function updateModelBadge() { 5322 - const badge = chatUI.container.querySelector('#ai-model-badge'); 5323 - const opt = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 5324 - badge.textContent = opt ? opt.label : chatConfig.model.split('/').pop() || ''; 5325 - } 5326 - updateModelBadge(); 5327 - 5328 - let settingsShownOnce = false; 5329 - 5330 - function toggleChatPanel() { 5331 - const isOpen = chatUI.container.style.display !== 'none'; 5332 - if (isOpen) { 5333 - chatUI.container.style.display = 'none'; 5334 - document.getElementById('btn-ai-chat').classList.remove('active'); 5335 - } else { 5336 - chatUI.container.style.display = ''; 5337 - document.getElementById('btn-ai-chat').classList.add('active'); 5338 - if (!isConfigured(chatConfig) && !settingsShownOnce) { 5339 - chatUI.settingsPanel.style.display = ''; 5340 - settingsShownOnce = true; 5341 - } 5342 - chatUI.input.focus(); 5343 - } 5344 - } 5345 - 5346 - document.getElementById('btn-ai-chat').addEventListener('click', toggleChatPanel); 5347 - chatUI.closeBtn.addEventListener('click', toggleChatPanel); 5348 - 5349 - // Keyboard shortcut: Cmd+Shift+L 5350 - document.addEventListener('keydown', (e) => { 5351 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'l') { 5352 - e.preventDefault(); 5353 - toggleChatPanel(); 5354 - } 5308 + const chatWiring = initChatWiring({ 5309 + chatUI, 5310 + chatState, 5311 + chatConfig: loadConfig(), 5312 + toggleBtn: document.getElementById('btn-ai-chat'), 5313 + editorType: 'sheet', 5314 + onSend: sendChatMessage, 5355 5315 }); 5356 5316 5357 - // Settings toggle 5358 - chatUI.settingsBtn.addEventListener('click', () => { 5359 - const panel = chatUI.settingsPanel; 5360 - panel.style.display = panel.style.display === 'none' ? '' : 'none'; 5361 - }); 5362 - 5363 - function persistChatSettings() { 5364 - const model = chatUI.modelSelect.value === '__custom' 5365 - ? chatUI.container.querySelector('#ai-model-custom').value.trim() 5366 - : chatUI.modelSelect.value; 5367 - 5368 - chatConfig = { 5369 - endpoint: chatUI.endpointInput.value.trim(), 5370 - model: model || 'claude-sonnet-4-20250514', 5371 - maxTokens: chatConfig.maxTokens, 5372 - }; 5373 - saveConfig(chatConfig); 5374 - updateModelBadge(); 5375 - } 5376 - 5377 - chatUI.endpointInput.addEventListener('change', persistChatSettings); 5378 - chatUI.modelSelect.addEventListener('change', () => { 5379 - const customInput = chatUI.container.querySelector('#ai-model-custom'); 5380 - customInput.style.display = chatUI.modelSelect.value === '__custom' ? '' : 'none'; 5381 - persistChatSettings(); 5382 - }); 5383 - chatUI.container.querySelector('#ai-model-custom').addEventListener('change', persistChatSettings); 5384 - 5385 - chatUI.input.addEventListener('input', () => autoResizeTextarea(chatUI.input)); 5386 - 5387 5317 /** Extract spreadsheet content as text for AI context */ 5388 5318 function getSheetContextText() { 5389 5319 const cells = getCells(); ··· 5428 5358 const text = chatUI.input.value.trim(); 5429 5359 if (!text || chatState.loading) return; 5430 5360 5431 - if (!isConfigured(chatConfig)) { 5361 + const cfg = chatWiring.getConfig(); 5362 + if (!isConfigured(cfg)) { 5432 5363 chatUI.settingsPanel.style.display = ''; 5433 5364 chatUI.endpointInput.focus(); 5434 5365 return; ··· 5470 5401 let fullText = ''; 5471 5402 5472 5403 await streamChat( 5473 - chatConfig, 5404 + cfg, 5474 5405 chatState.messages, 5475 5406 systemPrompt, 5476 5407 { ··· 5518 5449 chatUI.stopBtn.style.display = 'none'; 5519 5450 } 5520 5451 5521 - chatUI.sendBtn.addEventListener('click', sendChatMessage); 5522 - chatUI.input.addEventListener('keydown', (e) => { 5523 - if (e.key === 'Enter' && !e.shiftKey) { 5524 - e.preventDefault(); 5525 - sendChatMessage(); 5526 - } 5527 - }); 5528 - 5529 - chatUI.stopBtn.addEventListener('click', () => { 5530 - chatState.abortController?.abort(); 5531 - chatState.loading = false; 5532 - chatUI.sendBtn.style.display = ''; 5533 - chatUI.stopBtn.style.display = 'none'; 5534 - }); 5535 - 5536 - chatUI.clearBtn.addEventListener('click', () => { 5537 - chatState.messages = []; 5538 - chatState.error = null; 5539 - chatUI.messageList.innerHTML = ` 5540 - <div class="ai-chat-empty" id="ai-chat-empty"> 5541 - <div class="ai-chat-empty-icon"> 5542 - <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5h18a1.5 1.5 0 0 1 1.5 1.5v10.5a1.5 1.5 0 0 1-1.5 1.5H7.5L3 22.5V6a1.5 1.5 0 0 1 1.5-1.5z"/><circle cx="8.25" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="12" cy="11.25" r="0.75" fill="currentColor" stroke="none"/><circle cx="15.75" cy="11.25" r="0.75" fill="currentColor" stroke="none"/></svg> 5543 - </div> 5544 - <div class="ai-chat-empty-text">Ask anything about your spreadsheet</div> 5545 - <div class="ai-chat-empty-hint">The AI can see your spreadsheet data when context is enabled</div> 5546 - </div> 5547 - `; 5548 - }); 5549 5452 5550 5453 // --- Initial render --- 5551 5454 ensureSheet(0);
+119
tests/ai-chat.test.ts
··· 16 16 MODEL_OPTIONS, 17 17 escapeHtml, 18 18 appendActionCard, 19 + initChatWiring, 19 20 type ChatConfig, 20 21 type ChatMessage, 21 22 type EditorType, ··· 1166 1167 expect(card.textContent).toContain('Set'); 1167 1168 }); 1168 1169 }); 1170 + 1171 + // ── initChatWiring ──────────────────────────────────────────────────── 1172 + 1173 + describe('AI Chat — initChatWiring', () => { 1174 + beforeEach(() => { 1175 + mockLS.clear(); 1176 + document.body.innerHTML = ''; 1177 + }); 1178 + 1179 + function setup(editorType: EditorType = 'doc') { 1180 + const chatUI = createChatSidebar(); 1181 + document.body.appendChild(chatUI.container); 1182 + const chatState = createChatState(); 1183 + const toggleBtn = document.createElement('button'); 1184 + document.body.appendChild(toggleBtn); 1185 + const onSend = vi.fn(); 1186 + const chatConfig = loadConfig(); 1187 + 1188 + const wiring = initChatWiring({ 1189 + chatUI, 1190 + chatState, 1191 + chatConfig, 1192 + toggleBtn, 1193 + editorType, 1194 + onSend, 1195 + }); 1196 + 1197 + return { chatUI, chatState, toggleBtn, onSend, wiring }; 1198 + } 1199 + 1200 + it('getConfig returns initial config', () => { 1201 + const { wiring } = setup(); 1202 + const cfg = wiring.getConfig(); 1203 + expect(cfg.endpoint).toBe('http://ai'); 1204 + expect(cfg.model).toBe('claude-sonnet-4-20250514'); 1205 + }); 1206 + 1207 + it('getConfig reflects updated settings after persist', () => { 1208 + const { chatUI, wiring } = setup(); 1209 + chatUI.endpointInput.value = 'http://custom-endpoint'; 1210 + chatUI.modelSelect.value = 'gpt-4o'; 1211 + wiring.persistSettings(); 1212 + const cfg = wiring.getConfig(); 1213 + expect(cfg.endpoint).toBe('http://custom-endpoint'); 1214 + expect(cfg.model).toBe('gpt-4o'); 1215 + }); 1216 + 1217 + it('togglePanel shows and hides the sidebar', () => { 1218 + const { chatUI, toggleBtn, wiring } = setup(); 1219 + expect(chatUI.container.style.display).toBe('none'); 1220 + wiring.togglePanel(); 1221 + expect(chatUI.container.style.display).toBe(''); 1222 + expect(toggleBtn.classList.contains('active')).toBe(true); 1223 + wiring.togglePanel(); 1224 + expect(chatUI.container.style.display).toBe('none'); 1225 + expect(toggleBtn.classList.contains('active')).toBe(false); 1226 + }); 1227 + 1228 + it('send button calls onSend callback', () => { 1229 + const { chatUI, onSend } = setup(); 1230 + chatUI.sendBtn.click(); 1231 + expect(onSend).toHaveBeenCalledOnce(); 1232 + }); 1233 + 1234 + it('Enter key in input calls onSend', () => { 1235 + const { chatUI, onSend } = setup(); 1236 + chatUI.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); 1237 + expect(onSend).toHaveBeenCalledOnce(); 1238 + }); 1239 + 1240 + it('Shift+Enter does not call onSend', () => { 1241 + const { chatUI, onSend } = setup(); 1242 + chatUI.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })); 1243 + expect(onSend).not.toHaveBeenCalled(); 1244 + }); 1245 + 1246 + it('stop button aborts and resets loading state', () => { 1247 + const { chatUI, chatState } = setup(); 1248 + chatState.loading = true; 1249 + chatState.abortController = new AbortController(); 1250 + const abortSpy = vi.spyOn(chatState.abortController, 'abort'); 1251 + chatUI.stopBtn.click(); 1252 + expect(abortSpy).toHaveBeenCalled(); 1253 + expect(chatState.loading).toBe(false); 1254 + expect(chatUI.sendBtn.style.display).toBe(''); 1255 + expect(chatUI.stopBtn.style.display).toBe('none'); 1256 + }); 1257 + 1258 + it('clear button resets messages and shows empty state', () => { 1259 + const { chatUI, chatState } = setup(); 1260 + chatState.messages.push({ role: 'user', content: 'hello', ts: Date.now() }); 1261 + chatUI.clearBtn.click(); 1262 + expect(chatState.messages).toEqual([]); 1263 + expect(chatState.error).toBeNull(); 1264 + expect(chatUI.messageList.querySelector('.ai-chat-empty')).not.toBeNull(); 1265 + }); 1266 + 1267 + it('clear button uses correct label for sheet editor type', () => { 1268 + const { chatUI } = setup('sheet'); 1269 + chatUI.clearBtn.click(); 1270 + expect(chatUI.messageList.textContent).toContain('spreadsheet'); 1271 + }); 1272 + 1273 + it('clear button uses correct label for doc editor type', () => { 1274 + const { chatUI } = setup('doc'); 1275 + chatUI.clearBtn.click(); 1276 + expect(chatUI.messageList.textContent).toContain('document'); 1277 + }); 1278 + 1279 + it('persists settings to localStorage', () => { 1280 + const { chatUI, wiring } = setup(); 1281 + chatUI.endpointInput.value = 'http://new-api'; 1282 + chatUI.modelSelect.value = 'gpt-4o-mini'; 1283 + wiring.persistSettings(); 1284 + expect(mockLS.getItem('tools-ai-endpoint')).toBe('http://new-api'); 1285 + expect(mockLS.getItem('tools-ai-model')).toBe('gpt-4o-mini'); 1286 + }); 1287 + });