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 on all editors with content action system (#160)

scott 335179d9 d5202f05

+2365 -18
+20
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.12.0] — 2026-03-24 11 + 12 + ### Added 13 + - AI chat panel on all editor types (docs + sheets) with shared module (#229) 14 + - AI content actions: AI can insert, replace, and suggest changes in documents (#231) 15 + - AI spreadsheet actions: AI can set cell values/formulas and clear ranges (#231) 16 + - Action cards in chat with Apply/Suggest/Dismiss buttons 17 + - "Allow content edits" toggle in AI chat settings 18 + - Selection-aware context: AI sees highlighted text when available 19 + - Sheet context extraction: AI can read spreadsheet data as TSV 20 + 21 + ### Changed 22 + - Moved ai-chat.ts from src/docs/ to src/lib/ (shared across editors) 23 + - Replaced emoji icons with SVG in sheets toolbar (history, share buttons) 24 + - System prompt adapts per editor type (writing assistant vs data assistant) 25 + 10 26 ## [0.11.0] — 2026-03-24 11 27 12 28 ### Added 29 + - Polish AI chat: Aperture default, remove API key, SVG icons (#224) 13 30 - Add AI chat panel for docs with Aperture and OpenRouter integration (#215) 14 31 15 32 ### Fixed 33 + - Fix: express.static serves HTML without no-cache headers (#227) 34 + - Add Clear-Site-Data header to bust Firefox cache + version bump (#226) 35 + - Fix stale SW in regular Firefox — force update mechanism (#225) 16 36 - Fix landing page "Failed to load documents" — Express route ordering caused /api/documents/trash to 404 (#214) 17 37 - Fix [object Object] display for hyperlinks and rich text in imported spreadsheets (#212) 18 38 - Fix silent snapshot save failures when document row didn't exist (#213)
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.10.0", 3 + "version": "0.12.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.10.0", 9 + "version": "0.12.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-collaboration": "^2.11.0",
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.11.1", 3 + "version": "0.12.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+88
src/css/app.css
··· 6978 6978 opacity: 0.85; 6979 6979 } 6980 6980 6981 + /* AI action cards */ 6982 + 6983 + .ai-action-card { 6984 + margin: var(--space-xs) var(--space-md); 6985 + padding: var(--space-sm); 6986 + border: 1px solid var(--color-border); 6987 + border-radius: var(--radius-md); 6988 + background: var(--color-surface); 6989 + font-size: 0.8125rem; 6990 + } 6991 + 6992 + .ai-action-card-desc { 6993 + display: flex; 6994 + align-items: center; 6995 + gap: var(--space-xs); 6996 + color: var(--color-text); 6997 + margin-bottom: var(--space-xs); 6998 + } 6999 + 7000 + .ai-action-card-desc .tb-icon { 7001 + flex-shrink: 0; 7002 + color: var(--color-accent); 7003 + } 7004 + 7005 + .ai-action-card-buttons { 7006 + display: flex; 7007 + gap: var(--space-xs); 7008 + } 7009 + 7010 + .ai-action-btn { 7011 + padding: 3px 10px; 7012 + border: 1px solid var(--color-border); 7013 + border-radius: var(--radius-sm); 7014 + background: var(--color-bg); 7015 + color: var(--color-text); 7016 + font-size: 0.75rem; 7017 + cursor: pointer; 7018 + transition: background var(--transition-fast), border-color var(--transition-fast); 7019 + } 7020 + 7021 + .ai-action-btn:hover { 7022 + border-color: var(--color-accent); 7023 + } 7024 + 7025 + .ai-action-btn--apply { 7026 + background: var(--color-accent); 7027 + color: white; 7028 + border-color: var(--color-accent); 7029 + } 7030 + 7031 + .ai-action-btn--apply:hover { 7032 + opacity: 0.85; 7033 + } 7034 + 7035 + .ai-action-btn--suggest { 7036 + background: oklch(0.75 0.12 145); 7037 + color: white; 7038 + border-color: oklch(0.75 0.12 145); 7039 + } 7040 + 7041 + .ai-action-btn--suggest:hover { 7042 + opacity: 0.85; 7043 + } 7044 + 7045 + .ai-action-btn--dismiss { 7046 + color: var(--color-text-muted); 7047 + } 7048 + 7049 + .ai-action-card-status { 7050 + font-size: 0.75rem; 7051 + color: var(--color-text-muted); 7052 + font-style: italic; 7053 + } 7054 + 7055 + .ai-action-card--applied { 7056 + border-color: var(--color-accent); 7057 + opacity: 0.7; 7058 + } 7059 + 7060 + .ai-action-card--suggested { 7061 + border-color: oklch(0.75 0.12 145); 7062 + opacity: 0.7; 7063 + } 7064 + 7065 + .ai-action-card--dismissed { 7066 + opacity: 0.4; 7067 + } 7068 + 6981 7069 .zen-mode .ai-chat-sidebar { 6982 7070 display: none !important; 6983 7071 }
+170 -9
src/docs/ai-chat.ts src/lib/ai-chat.ts
··· 85 85 86 86 // ── System prompt ────────────────────────────────────────────────────── 87 87 88 - export function buildSystemMessage(docTitle: string, docContext: string): string { 88 + export type EditorType = 'doc' | 'sheet'; 89 + 90 + export interface SystemMessageOptions { 91 + editorType?: EditorType; 92 + actionsEnabled?: boolean; 93 + selectionContext?: string; 94 + } 95 + 96 + export function buildSystemMessage(docTitle: string, docContext: string, editorTypeOrOpts: EditorType | SystemMessageOptions = 'doc'): string { 97 + const opts: SystemMessageOptions = typeof editorTypeOrOpts === 'string' 98 + ? { editorType: editorTypeOrOpts } 99 + : editorTypeOrOpts; 100 + const editorType = opts.editorType || 'doc'; 101 + const actionsEnabled = opts.actionsEnabled || false; 102 + 103 + const descriptions: Record<EditorType, { role: string; label: string }> = { 104 + doc: { role: 'a helpful writing assistant embedded in a document editor', label: 'document' }, 105 + sheet: { role: 'a helpful data assistant embedded in a spreadsheet editor', label: 'spreadsheet' }, 106 + }; 107 + const { role, label } = descriptions[editorType]; 89 108 const parts = [ 90 - 'You are a helpful writing assistant embedded in a document editor.', 109 + `You are ${role}.`, 91 110 'Be concise and direct. Use markdown formatting where helpful.', 92 111 ]; 93 - if (docTitle) parts.push(`The document is titled "${docTitle}".`); 112 + if (docTitle) parts.push(`The ${label} is titled "${docTitle}".`); 113 + if (opts.selectionContext) { 114 + parts.push(`The user has selected the following text:\n\n---\n${opts.selectionContext}\n---`); 115 + } 94 116 if (docContext) { 95 - const trimmed = docContext.length > 8000 96 - ? docContext.slice(0, 8000) + '\n\n[...truncated]' 117 + const maxLen = actionsEnabled ? 12000 : 8000; 118 + const trimmed = docContext.length > maxLen 119 + ? docContext.slice(0, maxLen) + '\n\n[...truncated]' 97 120 : docContext; 98 - parts.push(`Here is the current document content:\n\n---\n${trimmed}\n---`); 121 + parts.push(`Here is the current ${label} content:\n\n---\n${trimmed}\n---`); 122 + } 123 + if (actionsEnabled) { 124 + parts.push(buildActionInstructions(editorType)); 99 125 } 100 126 return parts.join('\n'); 101 127 } 102 128 129 + function buildActionInstructions(editorType: EditorType): string { 130 + const lines = [ 131 + '', 132 + '## Actions', 133 + 'You can take actions on the content by including action blocks in your response.', 134 + 'Each action block is a fenced code block with the language "action" containing a JSON object.', 135 + 'You may include multiple action blocks in one response alongside normal text.', 136 + '', 137 + ]; 138 + 139 + if (editorType === 'doc') { 140 + lines.push( 141 + 'Available document actions:', 142 + '', 143 + '- **doc_insert**: Insert text at a position.', 144 + ' ```action', 145 + ' {"type": "doc_insert", "position": "end", "content": "Text to insert"}', 146 + ' ```', 147 + ' Position can be "cursor", "start", or "end".', 148 + '', 149 + '- **doc_replace**: Find and replace text.', 150 + ' ```action', 151 + ' {"type": "doc_replace", "search": "old text", "replace": "new text"}', 152 + ' ```', 153 + '', 154 + '- **doc_suggest_insert**: Suggest inserting text (as a tracked change the user can accept/reject).', 155 + ' ```action', 156 + ' {"type": "doc_suggest_insert", "position": "end", "content": "Suggested text"}', 157 + ' ```', 158 + '', 159 + '- **doc_suggest_replace**: Suggest replacing text (as a tracked change).', 160 + ' ```action', 161 + ' {"type": "doc_suggest_replace", "search": "original text", "replace": "suggested replacement"}', 162 + ' ```', 163 + ); 164 + } else { 165 + lines.push( 166 + 'Available spreadsheet actions:', 167 + '', 168 + '- **sheet_set**: Set cell values or formulas.', 169 + ' ```action', 170 + ' {"type": "sheet_set", "cells": [{"ref": "A1", "value": "Hello"}, {"ref": "B1", "value": "=SUM(A1:A10)", "formula": true}]}', 171 + ' ```', 172 + '', 173 + '- **sheet_clear**: Clear a range of cells.', 174 + ' ```action', 175 + ' {"type": "sheet_clear", "range": "A1:B5"}', 176 + ' ```', 177 + ); 178 + } 179 + 180 + lines.push( 181 + '', 182 + 'Only use actions when the user asks you to make changes. For questions, just respond with text.', 183 + 'Always explain what you are doing before or after the action block.', 184 + ); 185 + 186 + return lines.join('\n'); 187 + } 188 + 103 189 // ── API call (streaming) ─────────────────────────────────────────────── 104 190 105 191 /** ··· 302 388 endpointInput: HTMLInputElement; 303 389 modelSelect: HTMLSelectElement; 304 390 contextToggle: HTMLInputElement; 391 + actionsToggle: HTMLInputElement; 305 392 } { 306 393 const container = document.createElement('div'); 307 394 container.className = 'ai-chat-sidebar'; ··· 342 429 Include document context 343 430 </label> 344 431 </div> 432 + <div class="ai-chat-settings-field ai-chat-context-row"> 433 + <label class="ai-chat-toggle-label"> 434 + <input type="checkbox" id="ai-actions-toggle" checked> 435 + Allow content edits 436 + </label> 437 + </div> 345 438 <details class="ai-chat-advanced"> 346 439 <summary>Advanced</summary> 347 440 <div class="ai-chat-settings-field"> ··· 356 449 <div class="ai-chat-empty-icon"> 357 450 <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> 358 451 </div> 359 - <div class="ai-chat-empty-text">Ask anything about your document</div> 360 - <div class="ai-chat-empty-hint">The AI can see your document content when context is enabled</div> 452 + <div class="ai-chat-empty-text">Ask anything about your content</div> 453 + <div class="ai-chat-empty-hint">The AI can see your content when context is enabled</div> 361 454 </div> 362 455 </div> 363 456 ··· 366 459 <textarea 367 460 class="ai-chat-input" 368 461 id="ai-chat-input" 369 - placeholder="Ask about your document..." 462 + placeholder="Ask anything..." 370 463 rows="1" 371 464 spellcheck="true" 372 465 ></textarea> ··· 393 486 endpointInput: container.querySelector('#ai-endpoint') as HTMLInputElement, 394 487 modelSelect: container.querySelector('#ai-model') as HTMLSelectElement, 395 488 contextToggle: container.querySelector('#ai-context-toggle') as HTMLInputElement, 489 + actionsToggle: container.querySelector('#ai-actions-toggle') as HTMLInputElement, 396 490 }; 397 491 } 398 492 ··· 445 539 textarea.style.height = 'auto'; 446 540 textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; 447 541 } 542 + 543 + // ── Action Cards ────────────────────────────────────────────────────── 544 + 545 + import { type AIAction, describeAction, isDocAction } from './ai-actions.js'; 546 + 547 + export interface ActionCardCallbacks { 548 + onApply: (action: AIAction) => void; 549 + onSuggest?: (action: AIAction) => void; 550 + onDismiss: (action: AIAction) => void; 551 + } 552 + 553 + /** 554 + * Render an action card below a chat bubble. 555 + * Shows a description and Apply/Suggest/Dismiss buttons. 556 + */ 557 + export function appendActionCard( 558 + list: HTMLElement, 559 + action: AIAction, 560 + callbacks: ActionCardCallbacks, 561 + ): HTMLElement { 562 + const card = document.createElement('div'); 563 + card.className = 'ai-action-card'; 564 + 565 + const description = escapeHtml(describeAction(action)); 566 + const showSuggest = callbacks.onSuggest && isDocAction(action); 567 + 568 + card.innerHTML = ` 569 + <div class="ai-action-card-desc"> 570 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2l1 1-8 8-3 1 1-3z"/><path d="M11 4l1 1"/></svg> 571 + <span>${description}</span> 572 + </div> 573 + <div class="ai-action-card-buttons"> 574 + <button class="ai-action-btn ai-action-btn--apply" title="Apply this change">Apply</button> 575 + ${showSuggest ? '<button class="ai-action-btn ai-action-btn--suggest" title="Insert as suggestion (track changes)">Suggest</button>' : ''} 576 + <button class="ai-action-btn ai-action-btn--dismiss" title="Dismiss">Dismiss</button> 577 + </div> 578 + `; 579 + 580 + const applyBtn = card.querySelector('.ai-action-btn--apply') as HTMLButtonElement; 581 + const dismissBtn = card.querySelector('.ai-action-btn--dismiss') as HTMLButtonElement; 582 + const suggestBtn = card.querySelector('.ai-action-btn--suggest') as HTMLButtonElement | null; 583 + 584 + applyBtn.addEventListener('click', () => { 585 + callbacks.onApply(action); 586 + card.classList.add('ai-action-card--applied'); 587 + card.querySelector('.ai-action-card-buttons')!.innerHTML = '<span class="ai-action-card-status">Applied</span>'; 588 + }); 589 + 590 + if (suggestBtn && callbacks.onSuggest) { 591 + const onSuggest = callbacks.onSuggest; 592 + suggestBtn.addEventListener('click', () => { 593 + onSuggest(action); 594 + card.classList.add('ai-action-card--suggested'); 595 + card.querySelector('.ai-action-card-buttons')!.innerHTML = '<span class="ai-action-card-status">Suggested</span>'; 596 + }); 597 + } 598 + 599 + dismissBtn.addEventListener('click', () => { 600 + card.classList.add('ai-action-card--dismissed'); 601 + card.querySelector('.ai-action-card-buttons')!.innerHTML = '<span class="ai-action-card-status">Dismissed</span>'; 602 + callbacks.onDismiss(action); 603 + }); 604 + 605 + list.appendChild(card); 606 + list.scrollTop = list.scrollHeight; 607 + return card; 608 + }
+214
src/docs/ai-doc-actions.ts
··· 1 + /** 2 + * AI Doc Action Executor — applies AI actions to TipTap documents. 3 + * 4 + * Supports inserting, replacing, and suggesting changes (track changes). 5 + * All operations go through TipTap's transaction system, so they are 6 + * Yjs-aware and will sync to collaborators. 7 + */ 8 + 9 + import type { Editor } from '@tiptap/core'; 10 + import { createSuggestionAttrs } from '../lib/suggesting.js'; 11 + import type { DocAction, DocInsertAction, DocReplaceAction, DocSuggestInsertAction, DocSuggestReplaceAction } from '../lib/ai-actions.js'; 12 + 13 + export interface ActionResult { 14 + success: boolean; 15 + error?: string; 16 + } 17 + 18 + /** 19 + * Execute a doc action on the TipTap editor. 20 + */ 21 + export function executeDocAction( 22 + editor: Editor, 23 + action: DocAction, 24 + ): ActionResult { 25 + switch (action.type) { 26 + case 'doc_insert': 27 + return executeInsert(editor, action); 28 + case 'doc_replace': 29 + return executeReplace(editor, action); 30 + case 'doc_suggest_insert': 31 + return executeSuggestInsert(editor, action); 32 + case 'doc_suggest_replace': 33 + return executeSuggestReplace(editor, action); 34 + } 35 + } 36 + 37 + function resolvePosition(editor: Editor, position: 'cursor' | 'start' | 'end'): number { 38 + switch (position) { 39 + case 'cursor': 40 + return editor.state.selection.head; 41 + case 'start': 42 + return 1; 43 + case 'end': 44 + return editor.state.doc.content.size - 1; 45 + } 46 + } 47 + 48 + function executeInsert(editor: Editor, action: DocInsertAction): ActionResult { 49 + const pos = resolvePosition(editor, action.position); 50 + const success = editor.chain() 51 + .focus() 52 + .insertContentAt(pos, action.content) 53 + .run(); 54 + return { success }; 55 + } 56 + 57 + function executeReplace(editor: Editor, action: DocReplaceAction): ActionResult { 58 + const { state } = editor; 59 + const docText = state.doc.textContent; 60 + const searchIdx = docText.indexOf(action.search); 61 + 62 + if (searchIdx === -1) { 63 + return { success: false, error: `Text not found: "${action.search.slice(0, 50)}"` }; 64 + } 65 + 66 + // Map text offset to ProseMirror position 67 + // Walk the document to find the actual position 68 + let found = false; 69 + const searchLen = action.search.length; 70 + 71 + state.doc.descendants((node, pos) => { 72 + if (found || !node.isText) return; 73 + const nodeText = node.text || ''; 74 + const idx = nodeText.indexOf(action.search); 75 + if (idx !== -1) { 76 + const from = pos + idx; 77 + const to = from + searchLen; 78 + editor.chain() 79 + .focus() 80 + .command(({ tr }) => { 81 + tr.replaceWith(from, to, action.replace 82 + ? state.schema.text(action.replace) 83 + : state.schema.text('')); 84 + return true; 85 + }) 86 + .run(); 87 + found = true; 88 + } 89 + }); 90 + 91 + if (!found) { 92 + // Fallback: try across node boundaries using the full-doc text 93 + // This handles cases where the search text spans multiple nodes 94 + let textOffset = 0; 95 + let fromPos = -1; 96 + 97 + state.doc.descendants((node, pos) => { 98 + if (fromPos !== -1) return; 99 + if (node.isText) { 100 + const nodeText = node.text || ''; 101 + // Check if search starts in this node 102 + const remaining = action.search.slice(0); 103 + const localStart = docText.indexOf(remaining, textOffset) - textOffset; 104 + if (localStart >= 0 && localStart < nodeText.length) { 105 + fromPos = pos + localStart; 106 + } 107 + textOffset += nodeText.length; 108 + } 109 + }); 110 + 111 + if (fromPos !== -1) { 112 + const toPos = fromPos + searchLen; 113 + editor.chain() 114 + .focus() 115 + .command(({ tr }) => { 116 + if (action.replace) { 117 + tr.replaceWith(fromPos, toPos, state.schema.text(action.replace)); 118 + } else { 119 + tr.delete(fromPos, toPos); 120 + } 121 + return true; 122 + }) 123 + .run(); 124 + found = true; 125 + } 126 + } 127 + 128 + return found 129 + ? { success: true } 130 + : { success: false, error: `Could not locate text in document structure` }; 131 + } 132 + 133 + function executeSuggestInsert(editor: Editor, action: DocSuggestInsertAction): ActionResult { 134 + const pos = resolvePosition(editor, action.position); 135 + const attrs = createSuggestionAttrs({ type: 'insert', author: 'AI' }); 136 + const markType = editor.state.schema.marks['suggestion-insert']; 137 + 138 + if (!markType) { 139 + return { success: false, error: 'Suggestion marks not available in this editor' }; 140 + } 141 + 142 + const success = editor.chain() 143 + .focus() 144 + .command(({ tr }) => { 145 + const mark = markType.create(attrs); 146 + const textNode = editor.state.schema.text(action.content, [mark]); 147 + tr.insert(pos, textNode); 148 + return true; 149 + }) 150 + .run(); 151 + 152 + return { success }; 153 + } 154 + 155 + function executeSuggestReplace(editor: Editor, action: DocSuggestReplaceAction): ActionResult { 156 + const { state } = editor; 157 + const suggestionId = createSuggestionAttrs({ type: 'delete', author: 'AI' }).suggestionId; 158 + const deleteMarkType = state.schema.marks['suggestion-delete']; 159 + const insertMarkType = state.schema.marks['suggestion-insert']; 160 + 161 + if (!deleteMarkType || !insertMarkType) { 162 + return { success: false, error: 'Suggestion marks not available in this editor' }; 163 + } 164 + 165 + // Find the search text in the document 166 + let fromPos = -1; 167 + let toPos = -1; 168 + 169 + state.doc.descendants((node, pos) => { 170 + if (fromPos !== -1) return; 171 + if (node.isText) { 172 + const nodeText = node.text || ''; 173 + const idx = nodeText.indexOf(action.search); 174 + if (idx !== -1) { 175 + fromPos = pos + idx; 176 + toPos = fromPos + action.search.length; 177 + } 178 + } 179 + }); 180 + 181 + if (fromPos === -1) { 182 + return { success: false, error: `Text not found: "${action.search.slice(0, 50)}"` }; 183 + } 184 + 185 + const timestamp = new Date().toISOString(); 186 + 187 + const success = editor.chain() 188 + .focus() 189 + .command(({ tr }) => { 190 + // Mark the old text as suggestion-delete 191 + const deleteMark = deleteMarkType.create({ 192 + suggestionId, 193 + author: 'AI', 194 + type: 'delete', 195 + timestamp, 196 + }); 197 + tr.addMark(fromPos, toPos, deleteMark); 198 + 199 + // Insert replacement text with suggestion-insert mark right after 200 + const insertMark = insertMarkType.create({ 201 + suggestionId, 202 + author: 'AI', 203 + type: 'insert', 204 + timestamp, 205 + }); 206 + const textNode = state.schema.text(action.replace, [insertMark]); 207 + tr.insert(toPos, textNode); 208 + 209 + return true; 210 + }) 211 + .run(); 212 + 213 + return { success }; 214 + }
+44 -3
src/docs/main.ts
··· 61 61 import { 62 62 createChatSidebar, createChatState, loadConfig, saveConfig, isConfigured, 63 63 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 64 - autoResizeTextarea, renderMarkdown, MODEL_OPTIONS, 64 + autoResizeTextarea, renderMarkdown, MODEL_OPTIONS, appendActionCard, 65 65 type ChatMessage, type ChatConfig, 66 - } from './ai-chat.js'; 66 + } from '../lib/ai-chat.js'; 67 + import { splitResponse, isDocAction } from '../lib/ai-actions.js'; 68 + import { executeDocAction } from './ai-doc-actions.js'; 67 69 import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS, ZEN_TRANSITION_MS } from './zen-mode.js'; 68 70 import { SLASH_COMMAND_ITEMS, SlashMenuState, filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 69 71 import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; ··· 2465 2467 // Build context 2466 2468 const docTitle = (titleInput as HTMLInputElement).value.trim() || 'Untitled'; 2467 2469 const includeContext = chatUI.contextToggle.checked; 2470 + const actionsEnabled = chatUI.actionsToggle.checked; 2468 2471 const docText = includeContext ? editor.getText() : ''; 2469 - const systemPrompt = buildSystemMessage(docTitle, docText); 2472 + const { from, to } = editor.state.selection; 2473 + const selectionText = from !== to ? editor.state.doc.textBetween(from, to) : ''; 2474 + const systemPrompt = buildSystemMessage(docTitle, docText, { 2475 + editorType: 'doc', 2476 + actionsEnabled, 2477 + selectionContext: selectionText || undefined, 2478 + }); 2470 2479 2471 2480 // Streaming response 2472 2481 const abortController = new AbortController(); ··· 2486 2495 onDone(text) { 2487 2496 if (text) { 2488 2497 chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 2498 + 2499 + // Parse and render action cards 2500 + if (actionsEnabled) { 2501 + const { displayText, actions } = splitResponse(text); 2502 + if (actions.length > 0) { 2503 + bubble.update(renderMarkdown(displayText)); 2504 + for (const action of actions) { 2505 + if (!isDocAction(action)) continue; 2506 + appendActionCard(chatUI.messageList, action, { 2507 + onApply: (a) => { 2508 + const result = executeDocAction(editor, a as Parameters<typeof executeDocAction>[1]); 2509 + if (!result.success && result.error) { 2510 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 2511 + } 2512 + }, 2513 + onSuggest: (a) => { 2514 + // Convert to suggestion variant if it's a direct action 2515 + const suggestAction = a.type === 'doc_insert' 2516 + ? { ...a, type: 'doc_suggest_insert' as const } 2517 + : a.type === 'doc_replace' 2518 + ? { ...a, type: 'doc_suggest_replace' as const } 2519 + : a; 2520 + const result = executeDocAction(editor, suggestAction as Parameters<typeof executeDocAction>[1]); 2521 + if (!result.success && result.error) { 2522 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Suggestion failed: ${result.error}`, ts: Date.now() }); 2523 + } 2524 + }, 2525 + onDismiss: () => {}, 2526 + }); 2527 + } 2528 + } 2529 + } 2489 2530 } 2490 2531 }, 2491 2532 onError(err) {
+216
src/lib/ai-actions.ts
··· 1 + /** 2 + * AI Action System — structured actions the AI can take on documents/spreadsheets. 3 + * 4 + * The AI emits action blocks in its response using fenced code blocks: 5 + * ```action 6 + * {"type": "doc_insert", "position": "end", "content": "Hello world"} 7 + * ``` 8 + * 9 + * This module parses, validates, and strips those action blocks. 10 + * Editor-specific executors live in docs/ai-doc-actions.ts and sheets/ai-sheet-actions.ts. 11 + */ 12 + 13 + // ── Action Types ────────────────────────────────────────────────────── 14 + 15 + export interface DocInsertAction { 16 + type: 'doc_insert'; 17 + position: 'cursor' | 'start' | 'end'; 18 + content: string; 19 + } 20 + 21 + export interface DocReplaceAction { 22 + type: 'doc_replace'; 23 + search: string; 24 + replace: string; 25 + all?: boolean; 26 + } 27 + 28 + export interface DocSuggestInsertAction { 29 + type: 'doc_suggest_insert'; 30 + position: 'cursor' | 'start' | 'end'; 31 + content: string; 32 + } 33 + 34 + export interface DocSuggestReplaceAction { 35 + type: 'doc_suggest_replace'; 36 + search: string; 37 + replace: string; 38 + } 39 + 40 + export interface SheetSetAction { 41 + type: 'sheet_set'; 42 + cells: Array<{ ref: string; value: string; formula?: boolean }>; 43 + } 44 + 45 + export interface SheetClearAction { 46 + type: 'sheet_clear'; 47 + range: string; // e.g. "A1:B5" 48 + } 49 + 50 + export type DocAction = DocInsertAction | DocReplaceAction | DocSuggestInsertAction | DocSuggestReplaceAction; 51 + export type SheetAction = SheetSetAction | SheetClearAction; 52 + export type AIAction = DocAction | SheetAction; 53 + 54 + // ── Validation ──────────────────────────────────────────────────────── 55 + 56 + export interface ValidationResult { 57 + valid: boolean; 58 + error?: string; 59 + } 60 + 61 + const CELL_REF_RE = /^[A-Z]{1,3}\d{1,5}$/; 62 + const RANGE_RE = /^[A-Z]{1,3}\d{1,5}:[A-Z]{1,3}\d{1,5}$/; 63 + 64 + const DOC_ACTION_TYPES = new Set(['doc_insert', 'doc_replace', 'doc_suggest_insert', 'doc_suggest_replace']); 65 + const SHEET_ACTION_TYPES = new Set(['sheet_set', 'sheet_clear']); 66 + const VALID_POSITIONS = new Set(['cursor', 'start', 'end']); 67 + 68 + export function validateAction(action: unknown): ValidationResult { 69 + if (!action || typeof action !== 'object') { 70 + return { valid: false, error: 'Action must be a non-null object' }; 71 + } 72 + 73 + const a = action as Record<string, unknown>; 74 + 75 + if (typeof a.type !== 'string') { 76 + return { valid: false, error: 'Action must have a string "type" field' }; 77 + } 78 + 79 + if (!DOC_ACTION_TYPES.has(a.type) && !SHEET_ACTION_TYPES.has(a.type)) { 80 + return { valid: false, error: `Unknown action type: ${a.type}` }; 81 + } 82 + 83 + switch (a.type) { 84 + case 'doc_insert': 85 + case 'doc_suggest_insert': 86 + if (typeof a.content !== 'string' || a.content.length === 0) { 87 + return { valid: false, error: `${a.type} requires non-empty "content"` }; 88 + } 89 + if (typeof a.position !== 'string' || !VALID_POSITIONS.has(a.position)) { 90 + return { valid: false, error: `${a.type} requires "position" to be cursor, start, or end` }; 91 + } 92 + break; 93 + 94 + case 'doc_replace': 95 + case 'doc_suggest_replace': 96 + if (typeof a.search !== 'string' || a.search.length === 0) { 97 + return { valid: false, error: `${a.type} requires non-empty "search"` }; 98 + } 99 + if (typeof a.replace !== 'string') { 100 + return { valid: false, error: `${a.type} requires a "replace" string` }; 101 + } 102 + break; 103 + 104 + case 'sheet_set': 105 + if (!Array.isArray(a.cells) || a.cells.length === 0) { 106 + return { valid: false, error: 'sheet_set requires non-empty "cells" array' }; 107 + } 108 + for (let i = 0; i < a.cells.length; i++) { 109 + const cell = a.cells[i] as Record<string, unknown>; 110 + if (typeof cell.ref !== 'string' || !CELL_REF_RE.test(cell.ref)) { 111 + return { valid: false, error: `sheet_set cells[${i}].ref is invalid: "${cell.ref}"` }; 112 + } 113 + if (typeof cell.value !== 'string' && typeof cell.value !== 'number') { 114 + return { valid: false, error: `sheet_set cells[${i}].value must be a string or number` }; 115 + } 116 + } 117 + break; 118 + 119 + case 'sheet_clear': 120 + if (typeof a.range !== 'string' || !RANGE_RE.test(a.range)) { 121 + return { valid: false, error: `sheet_clear requires a valid "range" (e.g. "A1:B5"), got: "${a.range}"` }; 122 + } 123 + break; 124 + } 125 + 126 + return { valid: true }; 127 + } 128 + 129 + // ── Action Parsing ──────────────────────────────────────────────────── 130 + 131 + const ACTION_BLOCK_RE = /```action\s*\n([\s\S]*?)```/g; 132 + 133 + /** 134 + * Parse action blocks from AI response text. 135 + * Returns validated actions and any parse errors. 136 + */ 137 + export function parseActions(text: string): { actions: AIAction[]; errors: string[] } { 138 + const actions: AIAction[] = []; 139 + const errors: string[] = []; 140 + 141 + let match: RegExpExecArray | null; 142 + // Reset lastIndex for global regex 143 + ACTION_BLOCK_RE.lastIndex = 0; 144 + 145 + while ((match = ACTION_BLOCK_RE.exec(text)) !== null) { 146 + const jsonStr = match[1].trim(); 147 + let parsed: unknown; 148 + 149 + try { 150 + parsed = JSON.parse(jsonStr); 151 + } catch { 152 + errors.push(`Malformed JSON in action block: ${jsonStr.slice(0, 100)}`); 153 + continue; 154 + } 155 + 156 + const validation = validateAction(parsed); 157 + if (!validation.valid) { 158 + errors.push(validation.error!); 159 + continue; 160 + } 161 + 162 + actions.push(parsed as AIAction); 163 + } 164 + 165 + return { actions, errors }; 166 + } 167 + 168 + /** 169 + * Strip action blocks from response text, leaving only conversational content. 170 + */ 171 + export function stripActions(text: string): string { 172 + return text.replace(ACTION_BLOCK_RE, '').replace(/\n{3,}/g, '\n\n').trim(); 173 + } 174 + 175 + /** 176 + * Split AI response into conversational text and parsed actions. 177 + */ 178 + export function splitResponse(text: string): { 179 + displayText: string; 180 + actions: AIAction[]; 181 + errors: string[]; 182 + } { 183 + const { actions, errors } = parseActions(text); 184 + const displayText = stripActions(text); 185 + return { displayText, actions, errors }; 186 + } 187 + 188 + // ── Action Descriptions (for UI) ────────────────────────────────────── 189 + 190 + /** Human-readable summary of what an action will do. */ 191 + export function describeAction(action: AIAction): string { 192 + switch (action.type) { 193 + case 'doc_insert': 194 + return `Insert text at ${action.position} of document`; 195 + case 'doc_replace': 196 + return `Replace "${action.search.slice(0, 40)}${action.search.length > 40 ? '...' : ''}" with new text`; 197 + case 'doc_suggest_insert': 198 + return `Suggest inserting text at ${action.position} of document`; 199 + case 'doc_suggest_replace': 200 + return `Suggest replacing "${action.search.slice(0, 40)}${action.search.length > 40 ? '...' : ''}"`; 201 + case 'sheet_set': 202 + return `Set ${action.cells.length} cell${action.cells.length > 1 ? 's' : ''}: ${action.cells.slice(0, 3).map((c) => c.ref).join(', ')}${action.cells.length > 3 ? '...' : ''}`; 203 + case 'sheet_clear': 204 + return `Clear range ${action.range}`; 205 + } 206 + } 207 + 208 + /** Check if an action is a doc-type action. */ 209 + export function isDocAction(action: AIAction): action is DocAction { 210 + return DOC_ACTION_TYPES.has(action.type); 211 + } 212 + 213 + /** Check if an action is a sheet-type action. */ 214 + export function isSheetAction(action: AIAction): action is SheetAction { 215 + return SHEET_ACTION_TYPES.has(action.type); 216 + }
+93
src/sheets/ai-sheet-actions.ts
··· 1 + /** 2 + * AI Sheet Action Executor — applies AI actions to spreadsheet cells. 3 + * 4 + * Uses dependency injection to avoid coupling to the monolithic main.ts. 5 + * All operations go through Yjs-backed setCellData, so changes sync to collaborators. 6 + */ 7 + 8 + import type { SheetAction, SheetSetAction, SheetClearAction } from '../lib/ai-actions.js'; 9 + 10 + export interface ActionResult { 11 + success: boolean; 12 + error?: string; 13 + } 14 + 15 + export interface SheetActionDeps { 16 + setCellData: (id: string, data: { v?: unknown; f?: string; s?: string }) => void; 17 + getCellData: (id: string) => { v: unknown; f: string; s: Record<string, unknown> } | null; 18 + cellId: (col: number, row: number) => string; 19 + parseRef: (ref: string) => { col: number; row: number } | null; 20 + letterToCol: (letter: string) => number; 21 + colToLetter: (col: number) => string; 22 + renderGrid: () => void; 23 + } 24 + 25 + /** 26 + * Execute a sheet action using the provided dependencies. 27 + */ 28 + export function executeSheetAction( 29 + action: SheetAction, 30 + deps: SheetActionDeps, 31 + ): ActionResult { 32 + switch (action.type) { 33 + case 'sheet_set': 34 + return executeSheetSet(action, deps); 35 + case 'sheet_clear': 36 + return executeSheetClear(action, deps); 37 + } 38 + } 39 + 40 + function executeSheetSet(action: SheetSetAction, deps: SheetActionDeps): ActionResult { 41 + for (const cell of action.cells) { 42 + const ref = deps.parseRef(cell.ref); 43 + if (!ref) { 44 + return { success: false, error: `Invalid cell reference: ${cell.ref}` }; 45 + } 46 + 47 + const id = deps.cellId(ref.col, ref.row); 48 + const value = String(cell.value); 49 + 50 + if (cell.formula || value.startsWith('=')) { 51 + // Store as formula — strip leading = if present 52 + const formula = value.startsWith('=') ? value.slice(1) : value; 53 + deps.setCellData(id, { v: '', f: formula }); 54 + } else { 55 + // Store as plain value — auto-detect numbers 56 + const numVal = Number(value); 57 + const v = value !== '' && !isNaN(numVal) ? numVal : value; 58 + deps.setCellData(id, { v, f: '' }); 59 + } 60 + } 61 + 62 + deps.renderGrid(); 63 + return { success: true }; 64 + } 65 + 66 + function executeSheetClear(action: SheetClearAction, deps: SheetActionDeps): ActionResult { 67 + const parts = action.range.split(':'); 68 + if (parts.length !== 2) { 69 + return { success: false, error: `Invalid range: ${action.range}` }; 70 + } 71 + 72 + const start = deps.parseRef(parts[0]); 73 + const end = deps.parseRef(parts[1]); 74 + 75 + if (!start || !end) { 76 + return { success: false, error: `Invalid range references: ${action.range}` }; 77 + } 78 + 79 + const minCol = Math.min(start.col, end.col); 80 + const maxCol = Math.max(start.col, end.col); 81 + const minRow = Math.min(start.row, end.row); 82 + const maxRow = Math.max(start.row, end.row); 83 + 84 + for (let row = minRow; row <= maxRow; row++) { 85 + for (let col = minCol; col <= maxCol; col++) { 86 + const id = deps.cellId(col, row); 87 + deps.setCellData(id, { v: '', f: '' }); 88 + } 89 + } 90 + 91 + deps.renderGrid(); 92 + return { success: true }; 93 + }
+10 -2
src/sheets/index.html
··· 31 31 <span class="topbar-spacer"></span> 32 32 <div class="collab-avatars" id="collab-avatars"></div> 33 33 <!-- Version history --> 34 - <button class="btn-icon" id="btn-history" title="Version history">&#128339;</button> 34 + <button class="btn-icon" id="btn-history" title="Version history"> 35 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg> 36 + </button> 37 + <!-- AI Chat --> 38 + <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 39 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 40 + </button> 35 41 <!-- Share button --> 36 - <button class="btn-icon" id="btn-share" title="Share spreadsheet">&#128279;</button> 42 + <button class="btn-icon" id="btn-share" title="Share spreadsheet"> 43 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="3.5" r="2"/><circle cx="4" cy="8" r="2"/><circle cx="12" cy="12.5" r="2"/><line x1="5.8" y1="9" x2="10.2" y2="11.5"/><line x1="10.2" y1="4.5" x2="5.8" y2="7"/></svg> 44 + </button> 37 45 <div class="save-indicator saved" id="save-indicator"> 38 46 <span class="save-dot save-dot--saved"></span> 39 47 <span id="save-text">Saved</span>
+258
src/sheets/main.ts
··· 43 43 import { isSparklineResult, drawSparkline } from './sparkline.js'; 44 44 import { buildSheetsPrintHtml } from '../lib/print-layout.js'; 45 45 import type { PrintCell, PrintRow, SheetsPrintData, SheetsPrintOptions } from '../lib/print-layout.js'; 46 + import { 47 + createChatSidebar, createChatState, loadConfig, saveConfig, isConfigured, 48 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 49 + autoResizeTextarea, renderMarkdown, MODEL_OPTIONS, appendActionCard, 50 + } from '../lib/ai-chat.js'; 51 + import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 52 + import { executeSheetAction } from './ai-sheet-actions.js'; 46 53 47 54 // --- Constants --- 48 55 const DEFAULT_ROWS = 100; ··· 5291 5298 document.addEventListener('mouseup', onMouseUp); 5292 5299 document.body.style.cursor = 'row-resize'; 5293 5300 } 5301 + 5302 + // ── AI Chat Panel ──────────────────────────────────────────────────────── 5303 + 5304 + const chatUI = createChatSidebar(); 5305 + document.getElementById('app').appendChild(chatUI.container); 5306 + 5307 + const chatState = createChatState(); 5308 + let chatConfig = loadConfig(); 5309 + 5310 + // Populate settings from saved config 5311 + chatUI.endpointInput.value = chatConfig.endpoint; 5312 + 5313 + // Set model dropdown to match config (or custom) 5314 + const knownModel = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 5315 + if (knownModel) { 5316 + chatUI.modelSelect.value = chatConfig.model; 5317 + } else if (chatConfig.model) { 5318 + chatUI.modelSelect.value = '__custom'; 5319 + const customInput = chatUI.container.querySelector('#ai-model-custom'); 5320 + customInput.style.display = ''; 5321 + customInput.value = chatConfig.model; 5322 + } 5323 + 5324 + function updateModelBadge() { 5325 + const badge = chatUI.container.querySelector('#ai-model-badge'); 5326 + const opt = MODEL_OPTIONS.find((m) => m.id === chatConfig.model); 5327 + badge.textContent = opt ? opt.label : chatConfig.model.split('/').pop() || ''; 5328 + } 5329 + updateModelBadge(); 5330 + 5331 + let settingsShownOnce = false; 5332 + 5333 + function toggleChatPanel() { 5334 + const isOpen = chatUI.container.style.display !== 'none'; 5335 + if (isOpen) { 5336 + chatUI.container.style.display = 'none'; 5337 + document.getElementById('btn-ai-chat').classList.remove('active'); 5338 + } else { 5339 + chatUI.container.style.display = ''; 5340 + document.getElementById('btn-ai-chat').classList.add('active'); 5341 + if (!isConfigured(chatConfig) && !settingsShownOnce) { 5342 + chatUI.settingsPanel.style.display = ''; 5343 + settingsShownOnce = true; 5344 + } 5345 + chatUI.input.focus(); 5346 + } 5347 + } 5348 + 5349 + document.getElementById('btn-ai-chat').addEventListener('click', toggleChatPanel); 5350 + chatUI.closeBtn.addEventListener('click', toggleChatPanel); 5351 + 5352 + // Keyboard shortcut: Cmd+Shift+L 5353 + document.addEventListener('keydown', (e) => { 5354 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'l') { 5355 + e.preventDefault(); 5356 + toggleChatPanel(); 5357 + } 5358 + }); 5359 + 5360 + // Settings toggle 5361 + chatUI.settingsBtn.addEventListener('click', () => { 5362 + const panel = chatUI.settingsPanel; 5363 + panel.style.display = panel.style.display === 'none' ? '' : 'none'; 5364 + }); 5365 + 5366 + function persistChatSettings() { 5367 + const model = chatUI.modelSelect.value === '__custom' 5368 + ? chatUI.container.querySelector('#ai-model-custom').value.trim() 5369 + : chatUI.modelSelect.value; 5370 + 5371 + chatConfig = { 5372 + endpoint: chatUI.endpointInput.value.trim(), 5373 + model: model || 'claude-sonnet-4-20250514', 5374 + maxTokens: chatConfig.maxTokens, 5375 + }; 5376 + saveConfig(chatConfig); 5377 + updateModelBadge(); 5378 + } 5379 + 5380 + chatUI.endpointInput.addEventListener('change', persistChatSettings); 5381 + chatUI.modelSelect.addEventListener('change', () => { 5382 + const customInput = chatUI.container.querySelector('#ai-model-custom'); 5383 + customInput.style.display = chatUI.modelSelect.value === '__custom' ? '' : 'none'; 5384 + persistChatSettings(); 5385 + }); 5386 + chatUI.container.querySelector('#ai-model-custom').addEventListener('change', persistChatSettings); 5387 + 5388 + chatUI.input.addEventListener('input', () => autoResizeTextarea(chatUI.input)); 5389 + 5390 + /** Extract spreadsheet content as text for AI context */ 5391 + function getSheetContextText() { 5392 + const cells = getCells(); 5393 + if (!cells || cells.size === 0) return ''; 5394 + 5395 + const rows = {}; 5396 + let maxCol = 0; 5397 + let maxRow = 0; 5398 + cells.forEach((cell, id) => { 5399 + const ref = parseRef(id); 5400 + if (!ref) return; 5401 + const { col, row } = ref; 5402 + if (!rows[row]) rows[row] = {}; 5403 + const c = cell instanceof Y.Map ? cell : null; 5404 + const formula = c ? c.get('f') : ''; 5405 + const value = c ? (c.get('v') ?? '') : ''; 5406 + rows[row][col] = formula ? `=${formula}` : String(value); 5407 + if (col > maxCol) maxCol = col; 5408 + if (row > maxRow) maxRow = row; 5409 + }); 5410 + 5411 + const lines = []; 5412 + // Header row with column letters 5413 + const headers = ['']; 5414 + for (let c = 0; c <= maxCol; c++) headers.push(colToLetter(c)); 5415 + lines.push(headers.join('\t')); 5416 + 5417 + for (let r = 0; r <= maxRow; r++) { 5418 + if (!rows[r]) continue; 5419 + const cols = [String(r + 1)]; 5420 + for (let c = 0; c <= maxCol; c++) { 5421 + cols.push(rows[r]?.[c] || ''); 5422 + } 5423 + // Skip completely empty rows 5424 + if (cols.slice(1).every(v => v === '')) continue; 5425 + lines.push(cols.join('\t')); 5426 + } 5427 + return lines.join('\n'); 5428 + } 5429 + 5430 + async function sendChatMessage() { 5431 + const text = chatUI.input.value.trim(); 5432 + if (!text || chatState.loading) return; 5433 + 5434 + if (!isConfigured(chatConfig)) { 5435 + chatUI.settingsPanel.style.display = ''; 5436 + chatUI.endpointInput.focus(); 5437 + return; 5438 + } 5439 + 5440 + const userMsg = { role: 'user', content: text, ts: Date.now() }; 5441 + chatState.messages.push(userMsg); 5442 + appendMessage(chatUI.messageList, userMsg); 5443 + 5444 + chatUI.input.value = ''; 5445 + chatUI.input.style.height = ''; 5446 + chatUI.sendBtn.style.display = 'none'; 5447 + chatUI.stopBtn.style.display = ''; 5448 + chatState.loading = true; 5449 + chatState.error = null; 5450 + 5451 + const sheetTitle = (titleInput).value.trim() || 'Untitled Spreadsheet'; 5452 + const includeContext = chatUI.contextToggle.checked; 5453 + const actionsEnabled = chatUI.actionsToggle.checked; 5454 + const sheetText = includeContext ? getSheetContextText() : ''; 5455 + const systemPrompt = buildSystemMessage(sheetTitle, sheetText, { 5456 + editorType: 'sheet', 5457 + actionsEnabled, 5458 + }); 5459 + 5460 + const sheetActionDeps = { 5461 + setCellData, 5462 + getCellData, 5463 + cellId, 5464 + parseRef, 5465 + letterToCol, 5466 + colToLetter, 5467 + renderGrid, 5468 + }; 5469 + 5470 + const abortController = new AbortController(); 5471 + chatState.abortController = abortController; 5472 + const bubble = appendStreamingBubble(chatUI.messageList); 5473 + let fullText = ''; 5474 + 5475 + await streamChat( 5476 + chatConfig, 5477 + chatState.messages, 5478 + systemPrompt, 5479 + { 5480 + onChunk(chunk) { 5481 + fullText += chunk; 5482 + bubble.update(renderMarkdown(fullText)); 5483 + }, 5484 + onDone(text) { 5485 + if (text) { 5486 + chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 5487 + 5488 + // Parse and render action cards 5489 + if (actionsEnabled) { 5490 + const { displayText, actions } = splitResponse(text); 5491 + if (actions.length > 0) { 5492 + bubble.update(renderMarkdown(displayText)); 5493 + for (const action of actions) { 5494 + if (!isSheetAction(action)) continue; 5495 + appendActionCard(chatUI.messageList, action, { 5496 + onApply: (a) => { 5497 + const result = executeSheetAction(a, sheetActionDeps); 5498 + if (!result.success && result.error) { 5499 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 5500 + } 5501 + }, 5502 + onDismiss: () => {}, 5503 + }); 5504 + } 5505 + } 5506 + } 5507 + } 5508 + }, 5509 + onError(err) { 5510 + chatState.error = err; 5511 + bubble.el.classList.add('ai-chat-bubble--error'); 5512 + bubble.update(`<span class="ai-chat-error">${err}</span>`); 5513 + }, 5514 + }, 5515 + abortController.signal, 5516 + ); 5517 + 5518 + chatState.loading = false; 5519 + chatState.abortController = null; 5520 + chatUI.sendBtn.style.display = ''; 5521 + chatUI.stopBtn.style.display = 'none'; 5522 + } 5523 + 5524 + chatUI.sendBtn.addEventListener('click', sendChatMessage); 5525 + chatUI.input.addEventListener('keydown', (e) => { 5526 + if (e.key === 'Enter' && !e.shiftKey) { 5527 + e.preventDefault(); 5528 + sendChatMessage(); 5529 + } 5530 + }); 5531 + 5532 + chatUI.stopBtn.addEventListener('click', () => { 5533 + chatState.abortController?.abort(); 5534 + chatState.loading = false; 5535 + chatUI.sendBtn.style.display = ''; 5536 + chatUI.stopBtn.style.display = 'none'; 5537 + }); 5538 + 5539 + chatUI.clearBtn.addEventListener('click', () => { 5540 + chatState.messages = []; 5541 + chatState.error = null; 5542 + chatUI.messageList.innerHTML = ` 5543 + <div class="ai-chat-empty" id="ai-chat-empty"> 5544 + <div class="ai-chat-empty-icon"> 5545 + <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> 5546 + </div> 5547 + <div class="ai-chat-empty-text">Ask anything about your spreadsheet</div> 5548 + <div class="ai-chat-empty-hint">The AI can see your spreadsheet data when context is enabled</div> 5549 + </div> 5550 + `; 5551 + }); 5294 5552 5295 5553 // --- Initial render --- 5296 5554 ensureSheet(0);
+308
tests/ai-actions.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + validateAction, 4 + parseActions, 5 + stripActions, 6 + splitResponse, 7 + describeAction, 8 + isDocAction, 9 + isSheetAction, 10 + type AIAction, 11 + } from '../src/lib/ai-actions.js'; 12 + 13 + // ── validateAction ──────────────────────────────────────────────────── 14 + 15 + describe('validateAction', () => { 16 + it('rejects null', () => { 17 + expect(validateAction(null).valid).toBe(false); 18 + }); 19 + 20 + it('rejects non-object', () => { 21 + expect(validateAction('hello').valid).toBe(false); 22 + }); 23 + 24 + it('rejects missing type', () => { 25 + expect(validateAction({ content: 'hi' }).valid).toBe(false); 26 + }); 27 + 28 + it('rejects unknown type', () => { 29 + expect(validateAction({ type: 'nuke_server' }).valid).toBe(false); 30 + }); 31 + 32 + // doc_insert 33 + it('validates doc_insert', () => { 34 + expect(validateAction({ type: 'doc_insert', position: 'end', content: 'Hello' }).valid).toBe(true); 35 + }); 36 + 37 + it('rejects doc_insert with empty content', () => { 38 + expect(validateAction({ type: 'doc_insert', position: 'end', content: '' }).valid).toBe(false); 39 + }); 40 + 41 + it('rejects doc_insert with bad position', () => { 42 + expect(validateAction({ type: 'doc_insert', position: 'middle', content: 'x' }).valid).toBe(false); 43 + }); 44 + 45 + it('accepts all valid positions for doc_insert', () => { 46 + for (const pos of ['cursor', 'start', 'end']) { 47 + expect(validateAction({ type: 'doc_insert', position: pos, content: 'x' }).valid).toBe(true); 48 + } 49 + }); 50 + 51 + // doc_replace 52 + it('validates doc_replace', () => { 53 + expect(validateAction({ type: 'doc_replace', search: 'old', replace: 'new' }).valid).toBe(true); 54 + }); 55 + 56 + it('rejects doc_replace with empty search', () => { 57 + expect(validateAction({ type: 'doc_replace', search: '', replace: 'new' }).valid).toBe(false); 58 + }); 59 + 60 + it('rejects doc_replace with missing replace', () => { 61 + expect(validateAction({ type: 'doc_replace', search: 'old' }).valid).toBe(false); 62 + }); 63 + 64 + it('accepts doc_replace with empty replace (deletion)', () => { 65 + expect(validateAction({ type: 'doc_replace', search: 'old', replace: '' }).valid).toBe(true); 66 + }); 67 + 68 + // doc_suggest_insert 69 + it('validates doc_suggest_insert', () => { 70 + expect(validateAction({ type: 'doc_suggest_insert', position: 'cursor', content: 'text' }).valid).toBe(true); 71 + }); 72 + 73 + // doc_suggest_replace 74 + it('validates doc_suggest_replace', () => { 75 + expect(validateAction({ type: 'doc_suggest_replace', search: 'old', replace: 'new' }).valid).toBe(true); 76 + }); 77 + 78 + // sheet_set 79 + it('validates sheet_set', () => { 80 + expect(validateAction({ type: 'sheet_set', cells: [{ ref: 'A1', value: 'hello' }] }).valid).toBe(true); 81 + }); 82 + 83 + it('validates sheet_set with numeric value', () => { 84 + expect(validateAction({ type: 'sheet_set', cells: [{ ref: 'B2', value: 42 }] }).valid).toBe(true); 85 + }); 86 + 87 + it('rejects sheet_set with empty cells array', () => { 88 + expect(validateAction({ type: 'sheet_set', cells: [] }).valid).toBe(false); 89 + }); 90 + 91 + it('rejects sheet_set with invalid cell ref', () => { 92 + expect(validateAction({ type: 'sheet_set', cells: [{ ref: '1A', value: 'x' }] }).valid).toBe(false); 93 + }); 94 + 95 + it('rejects sheet_set with lowercase cell ref', () => { 96 + expect(validateAction({ type: 'sheet_set', cells: [{ ref: 'a1', value: 'x' }] }).valid).toBe(false); 97 + }); 98 + 99 + it('accepts sheet_set with multi-letter col refs', () => { 100 + expect(validateAction({ type: 'sheet_set', cells: [{ ref: 'AA1', value: 'x' }, { ref: 'ZZZ99', value: 'y' }] }).valid).toBe(true); 101 + }); 102 + 103 + it('rejects sheet_set with overly long col ref', () => { 104 + expect(validateAction({ type: 'sheet_set', cells: [{ ref: 'AAAA1', value: 'x' }] }).valid).toBe(false); 105 + }); 106 + 107 + it('rejects sheet_set with missing value', () => { 108 + const r = validateAction({ type: 'sheet_set', cells: [{ ref: 'A1' }] }); 109 + expect(r.valid).toBe(false); 110 + expect(r.error).toContain('value'); 111 + }); 112 + 113 + it('identifies which cell index is invalid', () => { 114 + const r = validateAction({ type: 'sheet_set', cells: [{ ref: 'A1', value: 'ok' }, { ref: 'bad', value: 'x' }] }); 115 + expect(r.valid).toBe(false); 116 + expect(r.error).toContain('cells[1]'); 117 + }); 118 + 119 + // sheet_clear 120 + it('validates sheet_clear', () => { 121 + expect(validateAction({ type: 'sheet_clear', range: 'A1:B5' }).valid).toBe(true); 122 + }); 123 + 124 + it('rejects sheet_clear with single cell (not a range)', () => { 125 + expect(validateAction({ type: 'sheet_clear', range: 'A1' }).valid).toBe(false); 126 + }); 127 + 128 + it('rejects sheet_clear with invalid range format', () => { 129 + expect(validateAction({ type: 'sheet_clear', range: 'A1-B5' }).valid).toBe(false); 130 + }); 131 + }); 132 + 133 + // ── parseActions ────────────────────────────────────────────────────── 134 + 135 + describe('parseActions', () => { 136 + it('parses a single action block', () => { 137 + const text = 'Here is what I will do:\n\n```action\n{"type":"doc_insert","position":"end","content":"Hello"}\n```\n\nDone!'; 138 + const { actions, errors } = parseActions(text); 139 + expect(actions).toHaveLength(1); 140 + expect(actions[0].type).toBe('doc_insert'); 141 + expect(errors).toHaveLength(0); 142 + }); 143 + 144 + it('parses multiple action blocks', () => { 145 + const text = '```action\n{"type":"sheet_set","cells":[{"ref":"A1","value":"x"}]}\n```\n\nAnd also:\n\n```action\n{"type":"sheet_set","cells":[{"ref":"B1","value":"y"}]}\n```'; 146 + const { actions, errors } = parseActions(text); 147 + expect(actions).toHaveLength(2); 148 + expect(errors).toHaveLength(0); 149 + }); 150 + 151 + it('returns errors for malformed JSON', () => { 152 + const text = '```action\n{not valid json}\n```'; 153 + const { actions, errors } = parseActions(text); 154 + expect(actions).toHaveLength(0); 155 + expect(errors).toHaveLength(1); 156 + expect(errors[0]).toContain('Malformed JSON'); 157 + }); 158 + 159 + it('returns errors for invalid action structure', () => { 160 + const text = '```action\n{"type":"doc_insert","position":"middle","content":"x"}\n```'; 161 + const { actions, errors } = parseActions(text); 162 + expect(actions).toHaveLength(0); 163 + expect(errors).toHaveLength(1); 164 + }); 165 + 166 + it('parses valid actions and reports invalid ones separately', () => { 167 + const text = [ 168 + '```action\n{"type":"doc_insert","position":"end","content":"good"}\n```', 169 + '```action\n{"type":"bad_type"}\n```', 170 + '```action\n{"type":"doc_replace","search":"a","replace":"b"}\n```', 171 + ].join('\n\n'); 172 + const { actions, errors } = parseActions(text); 173 + expect(actions).toHaveLength(2); 174 + expect(errors).toHaveLength(1); 175 + }); 176 + 177 + it('returns empty for text with no action blocks', () => { 178 + const { actions, errors } = parseActions('Just a normal response with ```js\nconsole.log("hi")\n```'); 179 + expect(actions).toHaveLength(0); 180 + expect(errors).toHaveLength(0); 181 + }); 182 + 183 + it('handles action block with extra whitespace', () => { 184 + const text = '```action\n {"type":"doc_insert","position":"cursor","content":"hi"} \n```'; 185 + const { actions } = parseActions(text); 186 + expect(actions).toHaveLength(1); 187 + }); 188 + }); 189 + 190 + // ── stripActions ────────────────────────────────────────────────────── 191 + 192 + describe('stripActions', () => { 193 + it('removes action blocks from text', () => { 194 + const text = 'Before\n\n```action\n{"type":"doc_insert","position":"end","content":"x"}\n```\n\nAfter'; 195 + const result = stripActions(text); 196 + expect(result).toBe('Before\n\nAfter'); 197 + expect(result).not.toContain('action'); 198 + }); 199 + 200 + it('removes multiple action blocks', () => { 201 + const text = 'A\n\n```action\n{}\n```\n\nB\n\n```action\n{}\n```\n\nC'; 202 + const result = stripActions(text); 203 + expect(result).toBe('A\n\nB\n\nC'); 204 + }); 205 + 206 + it('preserves non-action code blocks', () => { 207 + const text = '```js\nconsole.log("hi")\n```'; 208 + expect(stripActions(text)).toBe(text); 209 + }); 210 + 211 + it('collapses excessive newlines left by removal', () => { 212 + const text = 'Before\n\n\n\n```action\n{}\n```\n\n\n\nAfter'; 213 + const result = stripActions(text); 214 + expect(result).not.toContain('\n\n\n'); 215 + }); 216 + 217 + it('returns empty string for action-only response', () => { 218 + const text = '```action\n{"type":"doc_insert","position":"end","content":"x"}\n```'; 219 + expect(stripActions(text)).toBe(''); 220 + }); 221 + }); 222 + 223 + // ── splitResponse ───────────────────────────────────────────────────── 224 + 225 + describe('splitResponse', () => { 226 + it('splits response into text and actions', () => { 227 + const text = 'I will add a greeting.\n\n```action\n{"type":"doc_insert","position":"end","content":"Hello!"}\n```\n\nDone!'; 228 + const { displayText, actions, errors } = splitResponse(text); 229 + expect(displayText).toBe('I will add a greeting.\n\nDone!'); 230 + expect(actions).toHaveLength(1); 231 + expect(errors).toHaveLength(0); 232 + }); 233 + 234 + it('handles response with no actions', () => { 235 + const { displayText, actions } = splitResponse('Just a normal reply.'); 236 + expect(displayText).toBe('Just a normal reply.'); 237 + expect(actions).toHaveLength(0); 238 + }); 239 + }); 240 + 241 + // ── describeAction ──────────────────────────────────────────────────── 242 + 243 + describe('describeAction', () => { 244 + it('describes doc_insert', () => { 245 + const desc = describeAction({ type: 'doc_insert', position: 'end', content: 'x' }); 246 + expect(desc).toContain('Insert'); 247 + expect(desc).toContain('end'); 248 + }); 249 + 250 + it('describes doc_replace and truncates long search', () => { 251 + const desc = describeAction({ type: 'doc_replace', search: 'a'.repeat(60), replace: 'b' }); 252 + expect(desc).toContain('Replace'); 253 + expect(desc).toContain('...'); 254 + }); 255 + 256 + it('describes sheet_set with cell count', () => { 257 + const desc = describeAction({ type: 'sheet_set', cells: [{ ref: 'A1', value: '1' }, { ref: 'B1', value: '2' }] }); 258 + expect(desc).toContain('2 cells'); 259 + expect(desc).toContain('A1'); 260 + }); 261 + 262 + it('describes sheet_set with more than 3 cells', () => { 263 + const cells = [{ ref: 'A1', value: '1' }, { ref: 'B1', value: '2' }, { ref: 'C1', value: '3' }, { ref: 'D1', value: '4' }]; 264 + const desc = describeAction({ type: 'sheet_set', cells }); 265 + expect(desc).toContain('...'); 266 + }); 267 + 268 + it('describes sheet_clear', () => { 269 + const desc = describeAction({ type: 'sheet_clear', range: 'A1:C10' }); 270 + expect(desc).toContain('Clear'); 271 + expect(desc).toContain('A1:C10'); 272 + }); 273 + 274 + it('describes doc_suggest_insert', () => { 275 + const desc = describeAction({ type: 'doc_suggest_insert', position: 'cursor', content: 'x' }); 276 + expect(desc).toContain('Suggest'); 277 + }); 278 + 279 + it('describes doc_suggest_replace', () => { 280 + const desc = describeAction({ type: 'doc_suggest_replace', search: 'old', replace: 'new' }); 281 + expect(desc).toContain('Suggest'); 282 + expect(desc).toContain('old'); 283 + }); 284 + }); 285 + 286 + // ── isDocAction / isSheetAction ─────────────────────────────────────── 287 + 288 + describe('isDocAction / isSheetAction', () => { 289 + it('identifies doc actions', () => { 290 + expect(isDocAction({ type: 'doc_insert', position: 'end', content: 'x' })).toBe(true); 291 + expect(isDocAction({ type: 'doc_replace', search: 'a', replace: 'b' })).toBe(true); 292 + expect(isDocAction({ type: 'doc_suggest_insert', position: 'cursor', content: 'x' })).toBe(true); 293 + expect(isDocAction({ type: 'doc_suggest_replace', search: 'a', replace: 'b' })).toBe(true); 294 + }); 295 + 296 + it('identifies sheet actions', () => { 297 + expect(isSheetAction({ type: 'sheet_set', cells: [{ ref: 'A1', value: 'x' }] })).toBe(true); 298 + expect(isSheetAction({ type: 'sheet_clear', range: 'A1:B5' })).toBe(true); 299 + }); 300 + 301 + it('doc actions are not sheet actions', () => { 302 + expect(isSheetAction({ type: 'doc_insert', position: 'end', content: 'x' } as AIAction)).toBe(false); 303 + }); 304 + 305 + it('sheet actions are not doc actions', () => { 306 + expect(isDocAction({ type: 'sheet_set', cells: [{ ref: 'A1', value: 'x' }] } as AIAction)).toBe(false); 307 + }); 308 + });
+778 -1
tests/ai-chat.test.ts
··· 8 8 buildSystemMessage, 9 9 renderMarkdown, 10 10 autoResizeTextarea, 11 + createChatSidebar, 12 + streamChat, 13 + sendChat, 14 + appendMessage, 15 + appendStreamingBubble, 11 16 MODEL_OPTIONS, 12 17 type ChatConfig, 13 - } from '../src/docs/ai-chat.js'; 18 + type ChatMessage, 19 + type EditorType, 20 + } from '../src/lib/ai-chat.js'; 14 21 15 22 // Mock localStorage for jsdom (which doesn't provide a full impl) 16 23 const store: Record<string, string> = {}; ··· 115 122 expect(msg).toContain(short); 116 123 expect(msg).not.toContain('[...truncated]'); 117 124 }); 125 + 126 + it('uses data assistant role for sheet editor type', () => { 127 + const msg = buildSystemMessage('Budget', 'A1: 100', 'sheet'); 128 + expect(msg).toContain('data assistant'); 129 + expect(msg).toContain('spreadsheet'); 130 + expect(msg).not.toContain('writing assistant'); 131 + }); 132 + 133 + it('defaults to doc editor type', () => { 134 + const msg = buildSystemMessage('Report', ''); 135 + expect(msg).toContain('writing assistant'); 136 + expect(msg).toContain('document'); 137 + }); 118 138 }); 119 139 120 140 // ── Markdown rendering ───────────────────────────────────────────────── ··· 200 220 expect(textarea.style.height).toBe('120px'); 201 221 }); 202 222 }); 223 + 224 + // ── EditorType system (comprehensive) ──────────────────────────────── 225 + 226 + describe('AI Chat — EditorType system', () => { 227 + it('explicit doc type produces writing assistant prompt', () => { 228 + const msg = buildSystemMessage('Report', 'Some content', 'doc'); 229 + expect(msg).toContain('writing assistant'); 230 + expect(msg).toContain('document'); 231 + expect(msg).not.toContain('data assistant'); 232 + expect(msg).not.toContain('spreadsheet'); 233 + }); 234 + 235 + it('explicit sheet type produces data assistant prompt', () => { 236 + const msg = buildSystemMessage('Budget', 'A1: 100', 'sheet'); 237 + expect(msg).toContain('data assistant'); 238 + expect(msg).toContain('spreadsheet'); 239 + expect(msg).not.toContain('writing assistant'); 240 + expect(msg).not.toContain('document'); 241 + }); 242 + 243 + it('sheet type uses spreadsheet label for title context', () => { 244 + const msg = buildSystemMessage('Q4 Budget', '', 'sheet'); 245 + expect(msg).toContain('spreadsheet is titled "Q4 Budget"'); 246 + }); 247 + 248 + it('doc type uses document label for title context', () => { 249 + const msg = buildSystemMessage('My Report', '', 'doc'); 250 + expect(msg).toContain('document is titled "My Report"'); 251 + }); 252 + 253 + it('sheet type uses spreadsheet label for content context', () => { 254 + const msg = buildSystemMessage('', 'A1: Revenue', 'sheet'); 255 + expect(msg).toContain('spreadsheet content'); 256 + }); 257 + 258 + it('doc type uses document label for content context', () => { 259 + const msg = buildSystemMessage('', 'Hello world', 'doc'); 260 + expect(msg).toContain('document content'); 261 + }); 262 + 263 + it('omitting editorType defaults to doc behavior', () => { 264 + const explicit = buildSystemMessage('Title', 'Content', 'doc'); 265 + const implicit = buildSystemMessage('Title', 'Content'); 266 + expect(implicit).toBe(explicit); 267 + }); 268 + 269 + it('truncates long context at 8000 chars for sheet type too', () => { 270 + const long = 'x'.repeat(10000); 271 + const msg = buildSystemMessage('Sheet', long, 'sheet'); 272 + expect(msg).toContain('[...truncated]'); 273 + expect(msg.length).toBeLessThan(long.length + 500); 274 + }); 275 + 276 + it('both types include markdown instruction', () => { 277 + const doc = buildSystemMessage('', '', 'doc'); 278 + const sheet = buildSystemMessage('', '', 'sheet'); 279 + expect(doc).toContain('markdown'); 280 + expect(sheet).toContain('markdown'); 281 + }); 282 + 283 + it('both types include concise/direct instruction', () => { 284 + const doc = buildSystemMessage('', '', 'doc'); 285 + const sheet = buildSystemMessage('', '', 'sheet'); 286 + expect(doc).toContain('concise'); 287 + expect(sheet).toContain('concise'); 288 + }); 289 + }); 290 + 291 + // ── createChatSidebar DOM structure ────────────────────────────────── 292 + 293 + describe('AI Chat — createChatSidebar DOM structure', () => { 294 + it('returns all expected element refs', () => { 295 + const sidebar = createChatSidebar(); 296 + expect(sidebar.container).toBeInstanceOf(HTMLElement); 297 + expect(sidebar.messageList).toBeInstanceOf(HTMLElement); 298 + expect(sidebar.input).toBeInstanceOf(HTMLTextAreaElement); 299 + expect(sidebar.sendBtn).toBeInstanceOf(HTMLButtonElement); 300 + expect(sidebar.stopBtn).toBeInstanceOf(HTMLButtonElement); 301 + expect(sidebar.clearBtn).toBeInstanceOf(HTMLButtonElement); 302 + expect(sidebar.settingsBtn).toBeInstanceOf(HTMLButtonElement); 303 + expect(sidebar.closeBtn).toBeInstanceOf(HTMLButtonElement); 304 + expect(sidebar.settingsPanel).toBeInstanceOf(HTMLElement); 305 + expect(sidebar.endpointInput).toBeInstanceOf(HTMLInputElement); 306 + expect(sidebar.modelSelect).toBeInstanceOf(HTMLSelectElement); 307 + expect(sidebar.contextToggle).toBeInstanceOf(HTMLInputElement); 308 + }); 309 + 310 + it('container starts hidden', () => { 311 + const sidebar = createChatSidebar(); 312 + expect(sidebar.container.style.display).toBe('none'); 313 + }); 314 + 315 + it('container has correct id and class', () => { 316 + const sidebar = createChatSidebar(); 317 + expect(sidebar.container.id).toBe('ai-chat-sidebar'); 318 + expect(sidebar.container.className).toBe('ai-chat-sidebar'); 319 + }); 320 + 321 + it('settings panel starts hidden', () => { 322 + const sidebar = createChatSidebar(); 323 + expect(sidebar.settingsPanel.style.display).toBe('none'); 324 + }); 325 + 326 + it('stop button starts hidden', () => { 327 + const sidebar = createChatSidebar(); 328 + expect(sidebar.stopBtn.style.display).toBe('none'); 329 + }); 330 + 331 + it('model select contains all MODEL_OPTIONS plus custom option', () => { 332 + const sidebar = createChatSidebar(); 333 + const options = sidebar.modelSelect.querySelectorAll('option'); 334 + // MODEL_OPTIONS.length known models + 1 custom option 335 + expect(options.length).toBe(MODEL_OPTIONS.length + 1); 336 + }); 337 + 338 + it('model select options have correct values from MODEL_OPTIONS', () => { 339 + const sidebar = createChatSidebar(); 340 + const optionValues = Array.from(sidebar.modelSelect.options).map((o) => o.value); 341 + for (const model of MODEL_OPTIONS) { 342 + expect(optionValues).toContain(model.id); 343 + } 344 + }); 345 + 346 + it('model select includes a custom model option with value __custom', () => { 347 + const sidebar = createChatSidebar(); 348 + const optionValues = Array.from(sidebar.modelSelect.options).map((o) => o.value); 349 + expect(optionValues).toContain('__custom'); 350 + }); 351 + 352 + it('context toggle checkbox is checked by default', () => { 353 + const sidebar = createChatSidebar(); 354 + expect(sidebar.contextToggle.checked).toBe(true); 355 + }); 356 + 357 + it('context toggle is a checkbox input', () => { 358 + const sidebar = createChatSidebar(); 359 + expect(sidebar.contextToggle.type).toBe('checkbox'); 360 + }); 361 + 362 + it('input textarea has placeholder text', () => { 363 + const sidebar = createChatSidebar(); 364 + expect(sidebar.input.placeholder).toBeTruthy(); 365 + }); 366 + 367 + it('contains empty state message', () => { 368 + const sidebar = createChatSidebar(); 369 + const emptyText = sidebar.container.querySelector('.ai-chat-empty-text'); 370 + expect(emptyText).not.toBeNull(); 371 + expect(emptyText!.textContent).toContain('Ask anything'); 372 + }); 373 + 374 + it('contains header with AI Chat title', () => { 375 + const sidebar = createChatSidebar(); 376 + const title = sidebar.container.querySelector('.ai-chat-title'); 377 + expect(title).not.toBeNull(); 378 + expect(title!.textContent).toBe('AI Chat'); 379 + }); 380 + 381 + it('no ref is null (all querySelector lookups succeed)', () => { 382 + const sidebar = createChatSidebar(); 383 + for (const [key, value] of Object.entries(sidebar)) { 384 + expect(value).not.toBeNull(); 385 + expect(value).toBeDefined(); 386 + } 387 + }); 388 + }); 389 + 390 + // ── Config edge cases ──────────────────────────────────────────────── 391 + 392 + describe('AI Chat — Config edge cases', () => { 393 + beforeEach(() => { 394 + mockLS.clear(); 395 + }); 396 + 397 + it('isConfigured returns true even with empty model (only endpoint matters)', () => { 398 + const cfg: ChatConfig = { endpoint: 'http://ai', model: '', maxTokens: 4096 }; 399 + expect(isConfigured(cfg)).toBe(true); 400 + }); 401 + 402 + it('saveConfig with empty string endpoint persists empty string', () => { 403 + saveConfig({ endpoint: '' }); 404 + expect(localStorage.getItem('tools-ai-endpoint')).toBe(''); 405 + }); 406 + 407 + it('loadConfig returns default model when stored value is empty string', () => { 408 + // localStorage || operator means empty string falls through to default 409 + localStorage.setItem('tools-ai-model', ''); 410 + const cfg = loadConfig(); 411 + expect(cfg.model).toBe('claude-sonnet-4-20250514'); 412 + }); 413 + 414 + it('loadConfig returns default endpoint when stored value is empty string', () => { 415 + localStorage.setItem('tools-ai-endpoint', ''); 416 + const cfg = loadConfig(); 417 + expect(cfg.endpoint).toBe('http://ai'); 418 + }); 419 + 420 + it('config persistence round-trip preserves all fields', () => { 421 + const original = { endpoint: 'https://openrouter.ai/api/v1', model: 'meta-llama/llama-4-maverick' }; 422 + saveConfig(original); 423 + const loaded = loadConfig(); 424 + expect(loaded.endpoint).toBe(original.endpoint); 425 + expect(loaded.model).toBe(original.model); 426 + expect(loaded.maxTokens).toBe(4096); // always default 427 + }); 428 + 429 + it('saveConfig with undefined fields does not clear existing values', () => { 430 + saveConfig({ endpoint: 'http://custom', model: 'gpt-4o' }); 431 + saveConfig({}); // no fields provided 432 + const cfg = loadConfig(); 433 + expect(cfg.endpoint).toBe('http://custom'); 434 + expect(cfg.model).toBe('gpt-4o'); 435 + }); 436 + 437 + it('maxTokens is always the default constant regardless of storage', () => { 438 + saveConfig({ endpoint: 'http://ai', model: 'any' }); 439 + const cfg = loadConfig(); 440 + expect(cfg.maxTokens).toBe(4096); 441 + }); 442 + 443 + it('custom model ID round-trips through save and load', () => { 444 + saveConfig({ model: 'my-org/custom-fine-tuned-model-v2' }); 445 + const cfg = loadConfig(); 446 + expect(cfg.model).toBe('my-org/custom-fine-tuned-model-v2'); 447 + }); 448 + 449 + it('endpoint with trailing slash is persisted as-is by saveConfig', () => { 450 + saveConfig({ endpoint: 'http://ai/' }); 451 + const cfg = loadConfig(); 452 + expect(cfg.endpoint).toBe('http://ai/'); 453 + }); 454 + }); 455 + 456 + // ── appendMessage / appendStreamingBubble ──────────────────────────── 457 + 458 + describe('AI Chat — appendMessage', () => { 459 + it('appends a user message bubble with correct class', () => { 460 + const list = document.createElement('div'); 461 + const msg: ChatMessage = { role: 'user', content: 'Hello', ts: Date.now() }; 462 + const bubble = appendMessage(list, msg); 463 + expect(bubble.className).toContain('ai-chat-bubble--user'); 464 + expect(list.children.length).toBe(1); 465 + }); 466 + 467 + it('appends an assistant message bubble with rendered markdown', () => { 468 + const list = document.createElement('div'); 469 + const msg: ChatMessage = { role: 'assistant', content: '**bold**', ts: Date.now() }; 470 + const bubble = appendMessage(list, msg); 471 + expect(bubble.className).toContain('ai-chat-bubble--assistant'); 472 + expect(bubble.innerHTML).toContain('<strong>bold</strong>'); 473 + }); 474 + 475 + it('hides the empty state element when a message is appended', () => { 476 + const list = document.createElement('div'); 477 + const empty = document.createElement('div'); 478 + empty.className = 'ai-chat-empty'; 479 + list.appendChild(empty); 480 + 481 + const msg: ChatMessage = { role: 'user', content: 'Hi', ts: Date.now() }; 482 + appendMessage(list, msg); 483 + expect((empty as HTMLElement).style.display).toBe('none'); 484 + }); 485 + 486 + it('escapes HTML in user messages', () => { 487 + const list = document.createElement('div'); 488 + const msg: ChatMessage = { role: 'user', content: '<script>alert("xss")</script>', ts: Date.now() }; 489 + const bubble = appendMessage(list, msg); 490 + expect(bubble.innerHTML).not.toContain('<script>'); 491 + expect(bubble.innerHTML).toContain('&lt;script&gt;'); 492 + }); 493 + }); 494 + 495 + describe('AI Chat — appendStreamingBubble', () => { 496 + it('creates a streaming bubble with typing indicator', () => { 497 + const list = document.createElement('div'); 498 + const { el } = appendStreamingBubble(list); 499 + expect(el.className).toContain('ai-chat-bubble--streaming'); 500 + expect(el.querySelector('.ai-chat-typing')).not.toBeNull(); 501 + }); 502 + 503 + it('update replaces content and removes streaming class', () => { 504 + const list = document.createElement('div'); 505 + const { el, update } = appendStreamingBubble(list); 506 + update('<strong>Done</strong>'); 507 + expect(el.innerHTML).toBe('<strong>Done</strong>'); 508 + expect(el.className).not.toContain('ai-chat-bubble--streaming'); 509 + }); 510 + 511 + it('hides empty state when streaming bubble is added', () => { 512 + const list = document.createElement('div'); 513 + const empty = document.createElement('div'); 514 + empty.className = 'ai-chat-empty'; 515 + list.appendChild(empty); 516 + 517 + appendStreamingBubble(list); 518 + expect((empty as HTMLElement).style.display).toBe('none'); 519 + }); 520 + }); 521 + 522 + // ── streamChat (mocked fetch) ──────────────────────────────────────── 523 + 524 + describe('AI Chat — streamChat', () => { 525 + const baseConfig: ChatConfig = { endpoint: 'http://ai', model: 'test-model', maxTokens: 1024 }; 526 + const baseMessages: ChatMessage[] = [{ role: 'user', content: 'Hello', ts: Date.now() }]; 527 + const systemPrompt = 'You are a test assistant.'; 528 + 529 + beforeEach(() => { 530 + vi.restoreAllMocks(); 531 + }); 532 + 533 + it('calls fetch with correct URL, method, headers, and body', async () => { 534 + const mockReader = { 535 + read: vi.fn() 536 + .mockResolvedValueOnce({ done: true, value: undefined }), 537 + }; 538 + const mockResponse = { 539 + ok: true, 540 + body: { getReader: () => mockReader }, 541 + }; 542 + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as unknown as Response); 543 + 544 + const callbacks = { onChunk: vi.fn(), onDone: vi.fn(), onError: vi.fn() }; 545 + await streamChat(baseConfig, baseMessages, systemPrompt, callbacks); 546 + 547 + expect(globalThis.fetch).toHaveBeenCalledOnce(); 548 + const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; 549 + expect(url).toBe('http://ai/chat/completions'); 550 + expect(opts.method).toBe('POST'); 551 + expect(opts.headers['Content-Type']).toBe('application/json'); 552 + 553 + const body = JSON.parse(opts.body); 554 + expect(body.model).toBe('test-model'); 555 + expect(body.stream).toBe(true); 556 + expect(body.max_tokens).toBe(1024); 557 + expect(body.messages[0].role).toBe('system'); 558 + expect(body.messages[1].role).toBe('user'); 559 + expect(body.messages[1].content).toBe('Hello'); 560 + }); 561 + 562 + it('strips trailing slash from endpoint in URL', async () => { 563 + const mockReader = { 564 + read: vi.fn().mockResolvedValueOnce({ done: true, value: undefined }), 565 + }; 566 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 567 + ok: true, 568 + body: { getReader: () => mockReader }, 569 + } as unknown as Response); 570 + 571 + const cfg: ChatConfig = { endpoint: 'http://ai/', model: 'x', maxTokens: 512 }; 572 + await streamChat(cfg, [], 'sys', { onChunk: vi.fn(), onDone: vi.fn(), onError: vi.fn() }); 573 + 574 + const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; 575 + expect(url).toBe('http://ai/chat/completions'); 576 + }); 577 + 578 + it('streams SSE chunks and calls onChunk/onDone correctly', async () => { 579 + const encoder = new TextEncoder(); 580 + const sseData = [ 581 + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n', 582 + 'data: {"choices":[{"delta":{"content":" world"}}]}\n\n', 583 + 'data: [DONE]\n\n', 584 + ].join(''); 585 + 586 + const mockReader = { 587 + read: vi.fn() 588 + .mockResolvedValueOnce({ done: false, value: encoder.encode(sseData) }) 589 + .mockResolvedValueOnce({ done: true, value: undefined }), 590 + }; 591 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 592 + ok: true, 593 + body: { getReader: () => mockReader }, 594 + } as unknown as Response); 595 + 596 + const onChunk = vi.fn(); 597 + const onDone = vi.fn(); 598 + const onError = vi.fn(); 599 + 600 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk, onDone, onError }); 601 + 602 + expect(onChunk).toHaveBeenCalledWith('Hello'); 603 + expect(onChunk).toHaveBeenCalledWith(' world'); 604 + expect(onDone).toHaveBeenCalledWith('Hello world'); 605 + expect(onError).not.toHaveBeenCalled(); 606 + }); 607 + 608 + it('calls onError when fetch response is not ok', async () => { 609 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 610 + ok: false, 611 + status: 429, 612 + statusText: 'Too Many Requests', 613 + json: vi.fn().mockResolvedValue({ error: { message: 'Rate limited' } }), 614 + } as unknown as Response); 615 + 616 + const onChunk = vi.fn(); 617 + const onDone = vi.fn(); 618 + const onError = vi.fn(); 619 + 620 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk, onDone, onError }); 621 + 622 + expect(onError).toHaveBeenCalledOnce(); 623 + expect(onError.mock.calls[0][0]).toContain('429'); 624 + expect(onError.mock.calls[0][0]).toContain('Rate limited'); 625 + expect(onChunk).not.toHaveBeenCalled(); 626 + expect(onDone).not.toHaveBeenCalled(); 627 + }); 628 + 629 + it('calls onError with statusText when error body has no message', async () => { 630 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 631 + ok: false, 632 + status: 500, 633 + statusText: 'Internal Server Error', 634 + json: vi.fn().mockRejectedValue(new Error('not json')), 635 + } as unknown as Response); 636 + 637 + const onError = vi.fn(); 638 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk: vi.fn(), onDone: vi.fn(), onError }); 639 + 640 + expect(onError).toHaveBeenCalledOnce(); 641 + expect(onError.mock.calls[0][0]).toContain('Internal Server Error'); 642 + }); 643 + 644 + it('calls onError on network failure', async () => { 645 + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); 646 + 647 + const onError = vi.fn(); 648 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk: vi.fn(), onDone: vi.fn(), onError }); 649 + 650 + expect(onError).toHaveBeenCalledOnce(); 651 + expect(onError.mock.calls[0][0]).toContain('Failed to connect'); 652 + expect(onError.mock.calls[0][0]).toContain('Failed to fetch'); 653 + }); 654 + 655 + it('silently returns on AbortError from fetch', async () => { 656 + const abortErr = new DOMException('The operation was aborted', 'AbortError'); 657 + vi.spyOn(globalThis, 'fetch').mockRejectedValue(abortErr); 658 + 659 + const onChunk = vi.fn(); 660 + const onDone = vi.fn(); 661 + const onError = vi.fn(); 662 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk, onDone, onError }); 663 + 664 + expect(onError).not.toHaveBeenCalled(); 665 + expect(onDone).not.toHaveBeenCalled(); 666 + }); 667 + 668 + it('calls onError when response body is null', async () => { 669 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 670 + ok: true, 671 + body: null, 672 + } as unknown as Response); 673 + 674 + const onError = vi.fn(); 675 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk: vi.fn(), onDone: vi.fn(), onError }); 676 + 677 + expect(onError).toHaveBeenCalledOnce(); 678 + expect(onError.mock.calls[0][0]).toContain('No response body'); 679 + }); 680 + 681 + it('handles non-streaming JSON fallback when no SSE data received', async () => { 682 + const encoder = new TextEncoder(); 683 + const jsonResponse = JSON.stringify({ 684 + choices: [{ message: { content: 'Fallback response' } }], 685 + }); 686 + 687 + const mockReader = { 688 + read: vi.fn() 689 + .mockResolvedValueOnce({ done: false, value: encoder.encode(jsonResponse) }) 690 + .mockResolvedValueOnce({ done: true, value: undefined }), 691 + }; 692 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 693 + ok: true, 694 + body: { getReader: () => mockReader }, 695 + } as unknown as Response); 696 + 697 + const onChunk = vi.fn(); 698 + const onDone = vi.fn(); 699 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk, onDone, onError: vi.fn() }); 700 + 701 + expect(onDone).toHaveBeenCalledWith('Fallback response'); 702 + expect(onChunk).toHaveBeenCalledWith('Fallback response'); 703 + }); 704 + 705 + it('skips malformed SSE JSON chunks without crashing', async () => { 706 + const encoder = new TextEncoder(); 707 + const sseData = [ 708 + 'data: not-valid-json\n\n', 709 + 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', 710 + 'data: [DONE]\n\n', 711 + ].join(''); 712 + 713 + const mockReader = { 714 + read: vi.fn() 715 + .mockResolvedValueOnce({ done: false, value: encoder.encode(sseData) }) 716 + .mockResolvedValueOnce({ done: true, value: undefined }), 717 + }; 718 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 719 + ok: true, 720 + body: { getReader: () => mockReader }, 721 + } as unknown as Response); 722 + 723 + const onChunk = vi.fn(); 724 + const onDone = vi.fn(); 725 + const onError = vi.fn(); 726 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk, onDone, onError }); 727 + 728 + expect(onChunk).toHaveBeenCalledWith('OK'); 729 + expect(onDone).toHaveBeenCalledWith('OK'); 730 + expect(onError).not.toHaveBeenCalled(); 731 + }); 732 + 733 + it('passes abort signal to fetch', async () => { 734 + const mockReader = { 735 + read: vi.fn().mockResolvedValueOnce({ done: true, value: undefined }), 736 + }; 737 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 738 + ok: true, 739 + body: { getReader: () => mockReader }, 740 + } as unknown as Response); 741 + 742 + const controller = new AbortController(); 743 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk: vi.fn(), onDone: vi.fn(), onError: vi.fn() }, controller.signal); 744 + 745 + const [, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; 746 + expect(opts.signal).toBe(controller.signal); 747 + }); 748 + 749 + it('silently returns on AbortError during stream read', async () => { 750 + const abortErr = new DOMException('The operation was aborted', 'AbortError'); 751 + const mockReader = { 752 + read: vi.fn().mockRejectedValueOnce(abortErr), 753 + }; 754 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 755 + ok: true, 756 + body: { getReader: () => mockReader }, 757 + } as unknown as Response); 758 + 759 + const onError = vi.fn(); 760 + const onDone = vi.fn(); 761 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk: vi.fn(), onDone, onError }); 762 + 763 + expect(onError).not.toHaveBeenCalled(); 764 + expect(onDone).not.toHaveBeenCalled(); 765 + }); 766 + 767 + it('calls onError on non-abort stream read failure', async () => { 768 + const mockReader = { 769 + read: vi.fn().mockRejectedValueOnce(new Error('Connection reset')), 770 + }; 771 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 772 + ok: true, 773 + body: { getReader: () => mockReader }, 774 + } as unknown as Response); 775 + 776 + const onError = vi.fn(); 777 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk: vi.fn(), onDone: vi.fn(), onError }); 778 + 779 + expect(onError).toHaveBeenCalledOnce(); 780 + expect(onError.mock.calls[0][0]).toContain('Stream interrupted'); 781 + expect(onError.mock.calls[0][0]).toContain('Connection reset'); 782 + }); 783 + 784 + it('ignores SSE lines that do not start with data:', async () => { 785 + const encoder = new TextEncoder(); 786 + const sseData = [ 787 + ': comment line\n', 788 + 'event: message\n', 789 + 'data: {"choices":[{"delta":{"content":"yes"}}]}\n\n', 790 + 'data: [DONE]\n\n', 791 + ].join(''); 792 + 793 + const mockReader = { 794 + read: vi.fn() 795 + .mockResolvedValueOnce({ done: false, value: encoder.encode(sseData) }) 796 + .mockResolvedValueOnce({ done: true, value: undefined }), 797 + }; 798 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 799 + ok: true, 800 + body: { getReader: () => mockReader }, 801 + } as unknown as Response); 802 + 803 + const onChunk = vi.fn(); 804 + const onDone = vi.fn(); 805 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk, onDone, onError: vi.fn() }); 806 + 807 + expect(onChunk).toHaveBeenCalledTimes(1); 808 + expect(onChunk).toHaveBeenCalledWith('yes'); 809 + expect(onDone).toHaveBeenCalledWith('yes'); 810 + }); 811 + 812 + it('skips SSE deltas with no content field', async () => { 813 + const encoder = new TextEncoder(); 814 + const sseData = [ 815 + 'data: {"choices":[{"delta":{}}]}\n\n', 816 + 'data: {"choices":[{"delta":{"content":"actual"}}]}\n\n', 817 + 'data: [DONE]\n\n', 818 + ].join(''); 819 + 820 + const mockReader = { 821 + read: vi.fn() 822 + .mockResolvedValueOnce({ done: false, value: encoder.encode(sseData) }) 823 + .mockResolvedValueOnce({ done: true, value: undefined }), 824 + }; 825 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 826 + ok: true, 827 + body: { getReader: () => mockReader }, 828 + } as unknown as Response); 829 + 830 + const onChunk = vi.fn(); 831 + const onDone = vi.fn(); 832 + await streamChat(baseConfig, baseMessages, systemPrompt, { onChunk, onDone, onError: vi.fn() }); 833 + 834 + expect(onChunk).toHaveBeenCalledTimes(1); 835 + expect(onChunk).toHaveBeenCalledWith('actual'); 836 + expect(onDone).toHaveBeenCalledWith('actual'); 837 + }); 838 + }); 839 + 840 + // ── sendChat (non-streaming fallback) ──────────────────────────────── 841 + 842 + describe('AI Chat — sendChat', () => { 843 + const baseConfig: ChatConfig = { endpoint: 'http://ai', model: 'test-model', maxTokens: 1024 }; 844 + const baseMessages: ChatMessage[] = [{ role: 'user', content: 'Hello', ts: Date.now() }]; 845 + const systemPrompt = 'You are a test assistant.'; 846 + 847 + beforeEach(() => { 848 + vi.restoreAllMocks(); 849 + }); 850 + 851 + it('sends non-streaming request (no stream field in body)', async () => { 852 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 853 + ok: true, 854 + json: vi.fn().mockResolvedValue({ 855 + choices: [{ message: { content: 'Response text' } }], 856 + }), 857 + } as unknown as Response); 858 + 859 + const result = await sendChat(baseConfig, baseMessages, systemPrompt); 860 + 861 + expect(result).toBe('Response text'); 862 + const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; 863 + expect(url).toBe('http://ai/chat/completions'); 864 + const body = JSON.parse(opts.body); 865 + expect(body.stream).toBeUndefined(); 866 + expect(body.model).toBe('test-model'); 867 + expect(body.max_tokens).toBe(1024); 868 + }); 869 + 870 + it('returns empty string when choices have no content', async () => { 871 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 872 + ok: true, 873 + json: vi.fn().mockResolvedValue({ choices: [{ message: {} }] }), 874 + } as unknown as Response); 875 + 876 + const result = await sendChat(baseConfig, baseMessages, systemPrompt); 877 + expect(result).toBe(''); 878 + }); 879 + 880 + it('returns empty string when response has no choices', async () => { 881 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 882 + ok: true, 883 + json: vi.fn().mockResolvedValue({}), 884 + } as unknown as Response); 885 + 886 + const result = await sendChat(baseConfig, baseMessages, systemPrompt); 887 + expect(result).toBe(''); 888 + }); 889 + 890 + it('throws on non-ok response with error message from body', async () => { 891 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 892 + ok: false, 893 + status: 401, 894 + statusText: 'Unauthorized', 895 + json: vi.fn().mockResolvedValue({ error: { message: 'Invalid API key' } }), 896 + } as unknown as Response); 897 + 898 + await expect(sendChat(baseConfig, baseMessages, systemPrompt)) 899 + .rejects.toThrow('AI request failed (401): Invalid API key'); 900 + }); 901 + 902 + it('throws with statusText when error body is unparseable', async () => { 903 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 904 + ok: false, 905 + status: 502, 906 + statusText: 'Bad Gateway', 907 + json: vi.fn().mockRejectedValue(new Error('not json')), 908 + } as unknown as Response); 909 + 910 + await expect(sendChat(baseConfig, baseMessages, systemPrompt)) 911 + .rejects.toThrow('AI request failed (502): Bad Gateway'); 912 + }); 913 + 914 + it('throws on network failure', async () => { 915 + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); 916 + 917 + await expect(sendChat(baseConfig, baseMessages, systemPrompt)) 918 + .rejects.toThrow('Failed to fetch'); 919 + }); 920 + 921 + it('passes abort signal to fetch', async () => { 922 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 923 + ok: true, 924 + json: vi.fn().mockResolvedValue({ choices: [{ message: { content: 'ok' } }] }), 925 + } as unknown as Response); 926 + 927 + const controller = new AbortController(); 928 + await sendChat(baseConfig, baseMessages, systemPrompt, controller.signal); 929 + 930 + const [, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; 931 + expect(opts.signal).toBe(controller.signal); 932 + }); 933 + 934 + it('strips trailing slash from endpoint', async () => { 935 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 936 + ok: true, 937 + json: vi.fn().mockResolvedValue({ choices: [{ message: { content: 'ok' } }] }), 938 + } as unknown as Response); 939 + 940 + const cfg: ChatConfig = { endpoint: 'http://ai/', model: 'x', maxTokens: 512 }; 941 + await sendChat(cfg, [], 'sys'); 942 + 943 + const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; 944 + expect(url).toBe('http://ai/chat/completions'); 945 + }); 946 + 947 + it('includes system prompt as first message and preserves message order', async () => { 948 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 949 + ok: true, 950 + json: vi.fn().mockResolvedValue({ choices: [{ message: { content: 'ok' } }] }), 951 + } as unknown as Response); 952 + 953 + const msgs: ChatMessage[] = [ 954 + { role: 'user', content: 'first', ts: 1 }, 955 + { role: 'assistant', content: 'reply', ts: 2 }, 956 + { role: 'user', content: 'second', ts: 3 }, 957 + ]; 958 + await sendChat(baseConfig, msgs, 'System instructions'); 959 + 960 + const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body); 961 + expect(body.messages).toHaveLength(4); // system + 3 chat messages 962 + expect(body.messages[0]).toEqual({ role: 'system', content: 'System instructions' }); 963 + expect(body.messages[1]).toEqual({ role: 'user', content: 'first' }); 964 + expect(body.messages[2]).toEqual({ role: 'assistant', content: 'reply' }); 965 + expect(body.messages[3]).toEqual({ role: 'user', content: 'second' }); 966 + }); 967 + 968 + it('handles error body with string error field', async () => { 969 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ 970 + ok: false, 971 + status: 400, 972 + statusText: 'Bad Request', 973 + json: vi.fn().mockResolvedValue({ error: 'Malformed request body' }), 974 + } as unknown as Response); 975 + 976 + await expect(sendChat(baseConfig, baseMessages, systemPrompt)) 977 + .rejects.toThrow('AI request failed (400): Malformed request body'); 978 + }); 979 + });
+163
tests/ai-sheet-actions.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { executeSheetAction, type SheetActionDeps } from '../src/sheets/ai-sheet-actions.js'; 3 + import type { SheetSetAction, SheetClearAction } from '../src/lib/ai-actions.js'; 4 + 5 + function createMockDeps(): SheetActionDeps & { cells: Record<string, { v: unknown; f: string }> } { 6 + const cells: Record<string, { v: unknown; f: string }> = {}; 7 + return { 8 + cells, 9 + setCellData: vi.fn((id: string, data: { v?: unknown; f?: string }) => { 10 + cells[id] = { v: data.v ?? '', f: data.f ?? '' }; 11 + }), 12 + getCellData: vi.fn((id: string) => { 13 + const c = cells[id]; 14 + return c ? { v: c.v, f: c.f, s: {} } : null; 15 + }), 16 + cellId: vi.fn((col: number, row: number) => { 17 + const letter = String.fromCharCode(65 + col); 18 + return `${letter}${row + 1}`; 19 + }), 20 + parseRef: vi.fn((ref: string) => { 21 + const m = ref.match(/^([A-Z]+)(\d+)$/); 22 + if (!m) return null; 23 + const col = m[1].charCodeAt(0) - 65; 24 + const row = parseInt(m[2], 10) - 1; 25 + return { col, row }; 26 + }), 27 + letterToCol: vi.fn((letter: string) => letter.charCodeAt(0) - 65), 28 + colToLetter: vi.fn((col: number) => String.fromCharCode(65 + col)), 29 + renderGrid: vi.fn(), 30 + }; 31 + } 32 + 33 + // ── sheet_set ───────────────────────────────────────────────────────── 34 + 35 + describe('executeSheetAction — sheet_set', () => { 36 + it('sets a single cell value', () => { 37 + const deps = createMockDeps(); 38 + const action: SheetSetAction = { type: 'sheet_set', cells: [{ ref: 'A1', value: 'Hello' }] }; 39 + const result = executeSheetAction(action, deps); 40 + expect(result.success).toBe(true); 41 + expect(deps.setCellData).toHaveBeenCalledWith('A1', { v: 'Hello', f: '' }); 42 + expect(deps.renderGrid).toHaveBeenCalled(); 43 + }); 44 + 45 + it('sets multiple cells', () => { 46 + const deps = createMockDeps(); 47 + const action: SheetSetAction = { 48 + type: 'sheet_set', 49 + cells: [ 50 + { ref: 'A1', value: 'Name' }, 51 + { ref: 'B1', value: 'Age' }, 52 + { ref: 'A2', value: 'Alice' }, 53 + { ref: 'B2', value: '30' }, 54 + ], 55 + }; 56 + const result = executeSheetAction(action, deps); 57 + expect(result.success).toBe(true); 58 + expect(deps.setCellData).toHaveBeenCalledTimes(4); 59 + }); 60 + 61 + it('auto-detects numeric values', () => { 62 + const deps = createMockDeps(); 63 + const action: SheetSetAction = { type: 'sheet_set', cells: [{ ref: 'A1', value: '42' }] }; 64 + executeSheetAction(action, deps); 65 + expect(deps.setCellData).toHaveBeenCalledWith('A1', { v: 42, f: '' }); 66 + }); 67 + 68 + it('stores formula when formula flag is true', () => { 69 + const deps = createMockDeps(); 70 + const action: SheetSetAction = { 71 + type: 'sheet_set', 72 + cells: [{ ref: 'C1', value: '=SUM(A1:B1)', formula: true }], 73 + }; 74 + executeSheetAction(action, deps); 75 + expect(deps.setCellData).toHaveBeenCalledWith('C1', { v: '', f: 'SUM(A1:B1)' }); 76 + }); 77 + 78 + it('auto-detects formula from = prefix', () => { 79 + const deps = createMockDeps(); 80 + const action: SheetSetAction = { 81 + type: 'sheet_set', 82 + cells: [{ ref: 'C1', value: '=A1+B1' }], 83 + }; 84 + executeSheetAction(action, deps); 85 + expect(deps.setCellData).toHaveBeenCalledWith('C1', { v: '', f: 'A1+B1' }); 86 + }); 87 + 88 + it('returns error for invalid cell reference', () => { 89 + const deps = createMockDeps(); 90 + // Override parseRef to return null for bad ref 91 + deps.parseRef = vi.fn(() => null); 92 + const action: SheetSetAction = { type: 'sheet_set', cells: [{ ref: 'bad', value: 'x' }] }; 93 + const result = executeSheetAction(action, deps); 94 + expect(result.success).toBe(false); 95 + expect(result.error).toContain('Invalid cell reference'); 96 + }); 97 + 98 + it('keeps string values that look numeric but have leading zeros', () => { 99 + const deps = createMockDeps(); 100 + const action: SheetSetAction = { type: 'sheet_set', cells: [{ ref: 'A1', value: '007' }] }; 101 + executeSheetAction(action, deps); 102 + // "007" converts to 7 via Number(), so it becomes numeric 103 + // This is intentional — spreadsheets typically convert "007" to 7 104 + expect(deps.setCellData).toHaveBeenCalledWith('A1', { v: 7, f: '' }); 105 + }); 106 + 107 + it('preserves empty string values', () => { 108 + const deps = createMockDeps(); 109 + const action: SheetSetAction = { type: 'sheet_set', cells: [{ ref: 'A1', value: '' }] }; 110 + executeSheetAction(action, deps); 111 + expect(deps.setCellData).toHaveBeenCalledWith('A1', { v: '', f: '' }); 112 + }); 113 + }); 114 + 115 + // ── sheet_clear ─────────────────────────────────────────────────────── 116 + 117 + describe('executeSheetAction — sheet_clear', () => { 118 + it('clears a single-cell range', () => { 119 + const deps = createMockDeps(); 120 + const action: SheetClearAction = { type: 'sheet_clear', range: 'A1:A1' }; 121 + const result = executeSheetAction(action, deps); 122 + expect(result.success).toBe(true); 123 + expect(deps.setCellData).toHaveBeenCalledWith('A1', { v: '', f: '' }); 124 + expect(deps.renderGrid).toHaveBeenCalled(); 125 + }); 126 + 127 + it('clears a multi-cell range', () => { 128 + const deps = createMockDeps(); 129 + const action: SheetClearAction = { type: 'sheet_clear', range: 'A1:B3' }; 130 + const result = executeSheetAction(action, deps); 131 + expect(result.success).toBe(true); 132 + // A1, A2, A3, B1, B2, B3 = 6 cells 133 + expect(deps.setCellData).toHaveBeenCalledTimes(6); 134 + }); 135 + 136 + it('handles reversed range (end before start)', () => { 137 + const deps = createMockDeps(); 138 + const action: SheetClearAction = { type: 'sheet_clear', range: 'B3:A1' }; 139 + const result = executeSheetAction(action, deps); 140 + expect(result.success).toBe(true); 141 + expect(deps.setCellData).toHaveBeenCalledTimes(6); 142 + }); 143 + 144 + it('returns error for invalid range format', () => { 145 + const deps = createMockDeps(); 146 + const action: SheetClearAction = { type: 'sheet_clear', range: 'A1' }; 147 + const result = executeSheetAction(action, deps); 148 + expect(result.success).toBe(false); 149 + expect(result.error).toContain('Invalid range'); 150 + }); 151 + 152 + it('returns error for bad start ref in range', () => { 153 + const deps = createMockDeps(); 154 + const origParseRef = deps.parseRef; 155 + deps.parseRef = vi.fn((ref: string) => { 156 + if (ref === 'bad') return null; 157 + return (origParseRef as (ref: string) => { col: number; row: number } | null)(ref); 158 + }); 159 + const action: SheetClearAction = { type: 'sheet_clear', range: 'bad:B5' }; 160 + const result = executeSheetAction(action, deps); 161 + expect(result.success).toBe(false); 162 + }); 163 + });