diff --git a/inventory-server/src/routes/ai.js b/inventory-server/src/routes/ai.js index 4d41f52..2f3dcab 100644 --- a/inventory-server/src/routes/ai.js +++ b/inventory-server/src/routes/ai.js @@ -409,8 +409,12 @@ router.post('/validate/sanity-check', async (req, res) => { return res.status(400).json({ error: 'Products array is required' }); } + // Get pool from app.locals (set by server.js) + const pool = req.app.locals.pool; + const result = await aiService.runTask(aiService.TASK_IDS.SANITY_CHECK, { - products + products, + pool }); if (!result.success) { diff --git a/inventory-server/src/services/ai/index.js b/inventory-server/src/services/ai/index.js index a89b21e..cc18d01 100644 --- a/inventory-server/src/services/ai/index.js +++ b/inventory-server/src/services/ai/index.js @@ -327,7 +327,8 @@ async function runTask(taskId, payload = {}) { ...payload, // Inject dependencies tasks may need provider: groqProvider, - pool: appPool, + // Use pool from payload if provided (from route), fall back to stored appPool + pool: payload.pool || appPool, logger }); } diff --git a/inventory-server/src/services/ai/prompts/descriptionPrompts.js b/inventory-server/src/services/ai/prompts/descriptionPrompts.js index f6dfef4..ea3defc 100644 --- a/inventory-server/src/services/ai/prompts/descriptionPrompts.js +++ b/inventory-server/src/services/ai/prompts/descriptionPrompts.js @@ -5,6 +5,33 @@ * System and general prompts are loaded from the database. */ +/** + * Sanitize an issue string from AI response + * AI sometimes returns malformed strings with escape sequences + * + * @param {string} issue - Raw issue string + * @returns {string} Cleaned issue string + */ +function sanitizeIssue(issue) { + if (!issue || typeof issue !== 'string') return ''; + + let cleaned = issue + // Remove trailing backslashes (incomplete escapes) + .replace(/\\+$/, '') + // Fix malformed escaped quotes at end of string + .replace(/\\",?\)?$/, '') + // Clean up double-escaped quotes + .replace(/\\\\"/g, '"') + // Clean up single escaped quotes that aren't needed + .replace(/\\"/g, '"') + // Remove any remaining trailing punctuation artifacts + .replace(/[,\s]+$/, '') + // Trim whitespace + .trim(); + + return cleaned; +} + /** * Build the user prompt for description validation * Combines database prompts with product data @@ -50,13 +77,17 @@ function buildDescriptionUserPrompt(product, prompts) { // Add response format instructions parts.push(''); - parts.push('If the description is empty or very short, suggest a complete description based on the product name.'); + parts.push('CRITICAL RULES:'); + parts.push('- If isValid is false, you MUST provide a suggestion with the improved description'); + parts.push('- If there are ANY issues, isValid MUST be false and suggestion MUST contain the corrected text'); + parts.push('- If the description is empty or very short, write a complete description based on the product name'); + parts.push('- Only set isValid to true if there are ZERO issues and the description needs no changes'); parts.push(''); parts.push('RESPOND WITH JSON:'); parts.push(JSON.stringify({ - isValid: 'true/false', - suggestion: 'improved description if changes needed, or null if valid', - issues: ['issue 1', 'issue 2 (empty array if valid)'] + isValid: 'true if perfect, false if ANY changes needed', + suggestion: 'REQUIRED when isValid is false - the complete improved description', + issues: ['list each problem found (empty array only if isValid is true)'] }, null, 2)); return parts.join('\n'); @@ -72,11 +103,35 @@ function buildDescriptionUserPrompt(product, prompts) { function parseDescriptionResponse(parsed, content) { // If we got valid parsed JSON, use it if (parsed && typeof parsed.isValid === 'boolean') { - return { - isValid: parsed.isValid, - suggestion: parsed.suggestion || null, - issues: Array.isArray(parsed.issues) ? parsed.issues : [] - }; + // Sanitize issues - AI sometimes returns malformed escape sequences + const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : []; + const issues = rawIssues + .map(sanitizeIssue) + .filter(issue => issue.length > 0); + + const suggestion = parsed.suggestion || null; + + // IMPORTANT: LLMs sometimes return contradictory data (isValid: true with issues). + // If there are issues, treat as invalid regardless of what the AI said. + // Also if there's a suggestion, the AI thought something needed to change. + const isValid = parsed.isValid && issues.length === 0 && !suggestion; + + return { isValid, suggestion, issues }; + } + + // Handle case where isValid is a string "true"/"false" instead of boolean + if (parsed && typeof parsed.isValid === 'string') { + const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : []; + const issues = rawIssues + .map(sanitizeIssue) + .filter(issue => issue.length > 0); + const suggestion = parsed.suggestion || null; + const rawIsValid = parsed.isValid.toLowerCase() !== 'false'; + + // Same defensive logic: if there are issues, it's not valid + const isValid = rawIsValid && issues.length === 0 && !suggestion; + + return { isValid, suggestion, issues }; } // Try to extract from content if parsing failed @@ -100,11 +155,16 @@ function parseDescriptionResponse(parsed, content) { const issuesContent = issuesMatch[1]; const issueStrings = issuesContent.match(/"([^"]+)"/g); if (issueStrings) { - issues = issueStrings.map(s => s.replace(/"/g, '')); + issues = issueStrings + .map(s => sanitizeIssue(s.replace(/"/g, ''))) + .filter(issue => issue.length > 0); } } - return { isValid, suggestion, issues }; + // Same logic: if there are issues, it's not valid + const finalIsValid = isValid && issues.length === 0 && !suggestion; + + return { isValid: finalIsValid, suggestion, issues }; } catch { // Default to valid if we can't parse anything return { isValid: true, suggestion: null, issues: [] }; diff --git a/inventory-server/src/services/ai/prompts/namePrompts.js b/inventory-server/src/services/ai/prompts/namePrompts.js index 93ae459..a208bc0 100644 --- a/inventory-server/src/services/ai/prompts/namePrompts.js +++ b/inventory-server/src/services/ai/prompts/namePrompts.js @@ -5,6 +5,33 @@ * System and general prompts are loaded from the database. */ +/** + * Sanitize an issue string from AI response + * AI sometimes returns malformed strings with escape sequences + * + * @param {string} issue - Raw issue string + * @returns {string} Cleaned issue string + */ +function sanitizeIssue(issue) { + if (!issue || typeof issue !== 'string') return ''; + + let cleaned = issue + // Remove trailing backslashes (incomplete escapes) + .replace(/\\+$/, '') + // Fix malformed escaped quotes at end of string + .replace(/\\",?\)?$/, '') + // Clean up double-escaped quotes + .replace(/\\\\"/g, '"') + // Clean up single escaped quotes that aren't needed + .replace(/\\"/g, '"') + // Remove any remaining trailing punctuation artifacts + .replace(/[,\s]+$/, '') + // Trim whitespace + .trim(); + + return cleaned; +} + /** * Build the user prompt for name validation * Combines database prompts with product data @@ -13,7 +40,9 @@ * @param {string} product.name - Current product name * @param {string} [product.company_name] - Company name * @param {string} [product.line_name] - Product line name + * @param {string} [product.subline_name] - Product subline name * @param {string} [product.description] - Product description (for context) + * @param {string[]} [product.siblingNames] - Names of other products in the same line * @param {Object} prompts - Prompts loaded from database * @param {string} prompts.general - General naming conventions * @param {string} [prompts.companySpecific] - Company-specific rules @@ -40,11 +69,32 @@ function buildNameUserPrompt(product, prompts) { parts.push(`NAME: "${product.name || ''}"`); parts.push(`COMPANY: ${product.company_name || 'Unknown'}`); parts.push(`LINE: ${product.line_name || 'None'}`); + if (product.subline_name) { + parts.push(`SUBLINE: ${product.subline_name}`); + } if (product.description) { parts.push(`DESCRIPTION (for context): ${product.description.substring(0, 200)}`); } + // Add sibling context for naming decisions + if (product.siblingNames && product.siblingNames.length > 0) { + parts.push(''); + parts.push(`OTHER PRODUCTS IN THIS LINE (${product.siblingNames.length + 1} total including this one):`); + product.siblingNames.forEach(name => { + parts.push(`- ${name}`); + }); + parts.push(''); + parts.push('Use this context to determine:'); + parts.push('- If this product needs a differentiator (multiple similar products exist)'); + parts.push('- If naming is consistent with sibling products'); + parts.push('- Which naming pattern is appropriate (single vs multiple products in line)'); + } else if (product.line_name) { + parts.push(''); + parts.push('This appears to be the ONLY product in this line (no siblings in current batch).'); + parts.push('Use the single-product naming pattern: [Line Name] [Product Name] - [Company]'); + } + // Add response format instructions parts.push(''); parts.push('RESPOND WITH JSON:'); @@ -65,24 +115,62 @@ function buildNameUserPrompt(product, prompts) { * @returns {Object} */ function parseNameResponse(parsed, content) { + // Debug: Log what we're trying to parse + console.log('[parseNameResponse] Input:', { + hasParsed: !!parsed, + parsedIsValid: parsed?.isValid, + parsedType: typeof parsed?.isValid, + contentPreview: content?.substring(0, 3000) + }); + // If we got valid parsed JSON, use it if (parsed && typeof parsed.isValid === 'boolean') { - return { - isValid: parsed.isValid, - suggestion: parsed.suggestion || null, - issues: Array.isArray(parsed.issues) ? parsed.issues : [] - }; + // Sanitize issues - AI sometimes returns malformed escape sequences + const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : []; + const issues = rawIssues + .map(sanitizeIssue) + .filter(issue => issue.length > 0); + const suggestion = parsed.suggestion || null; + + // IMPORTANT: LLMs sometimes return contradictory data (isValid: true with issues). + // If there are issues, treat as invalid regardless of what the AI said. + const isValid = parsed.isValid && issues.length === 0 && !suggestion; + + return { isValid, suggestion, issues }; + } + + // Handle case where isValid is a string "true"/"false" instead of boolean + if (parsed && typeof parsed.isValid === 'string') { + const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : []; + const issues = rawIssues + .map(sanitizeIssue) + .filter(issue => issue.length > 0); + const suggestion = parsed.suggestion || null; + const rawIsValid = parsed.isValid.toLowerCase() !== 'false'; + + // Same defensive logic: if there are issues, it's not valid + const isValid = rawIsValid && issues.length === 0 && !suggestion; + + console.log('[parseNameResponse] Parsed isValid as string:', parsed.isValid, '→', isValid); + return { isValid, suggestion, issues }; } // Try to extract from content if parsing failed try { - // Look for isValid pattern - const isValidMatch = content.match(/"isValid"\s*:\s*(true|false)/i); + // Look for isValid pattern - handle both boolean and quoted string + // Matches: "isValid": true, "isValid": false, "isValid": "true", "isValid": "false" + const isValidMatch = content.match(/"isValid"\s*:\s*"?(true|false)"?/i); const isValid = isValidMatch ? isValidMatch[1].toLowerCase() === 'true' : true; - // Look for suggestion - const suggestionMatch = content.match(/"suggestion"\s*:\s*"([^"]+)"/); - const suggestion = suggestionMatch ? suggestionMatch[1] : null; + console.log('[parseNameResponse] Regex extraction:', { + isValidMatch: isValidMatch?.[0], + isValidValue: isValidMatch?.[1], + resultIsValid: isValid + }); + + // Look for suggestion - handle escaped quotes and null + const suggestionMatch = content.match(/"suggestion"\s*:\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|null)/); + const suggestion = suggestionMatch ? (suggestionMatch[1] || null) : null; // Look for issues array const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/); @@ -91,11 +179,16 @@ function parseNameResponse(parsed, content) { const issuesContent = issuesMatch[1]; const issueStrings = issuesContent.match(/"([^"]+)"/g); if (issueStrings) { - issues = issueStrings.map(s => s.replace(/"/g, '')); + issues = issueStrings + .map(s => sanitizeIssue(s.replace(/"/g, ''))) + .filter(issue => issue.length > 0); } } - return { isValid, suggestion, issues }; + // Same defensive logic: if there are issues, it's not valid + const finalIsValid = isValid && issues.length === 0 && !suggestion; + + return { isValid: finalIsValid, suggestion, issues }; } catch { // Default to valid if we can't parse anything return { isValid: true, suggestion: null, issues: [] }; diff --git a/inventory-server/src/services/ai/providers/groqProvider.js b/inventory-server/src/services/ai/providers/groqProvider.js index a4972a7..a4232c3 100644 --- a/inventory-server/src/services/ai/providers/groqProvider.js +++ b/inventory-server/src/services/ai/providers/groqProvider.js @@ -63,8 +63,33 @@ class GroqProvider { body.response_format = { type: 'json_object' }; } + // Debug: Log request being sent + console.log('[Groq] Request:', { + model: body.model, + temperature: body.temperature, + maxTokens: body.max_completion_tokens, + hasResponseFormat: !!body.response_format, + messageCount: body.messages?.length, + systemPromptLength: body.messages?.[0]?.content?.length, + userPromptLength: body.messages?.[1]?.content?.length + }); + const response = await this._makeRequest('chat/completions', body, timeoutMs); + // Debug: Log raw response structure + console.log('[Groq] Raw response:', { + hasChoices: !!response.choices, + choicesLength: response.choices?.length, + firstChoice: response.choices?.[0] ? { + finishReason: response.choices[0].finish_reason, + hasMessage: !!response.choices[0].message, + contentLength: response.choices[0].message?.content?.length, + contentPreview: response.choices[0].message?.content?.substring(0, 200) + } : null, + usage: response.usage, + model: response.model + }); + const content = response.choices?.[0]?.message?.content || ''; const usage = response.usage || {}; diff --git a/inventory-server/src/services/ai/tasks/descriptionValidationTask.js b/inventory-server/src/services/ai/tasks/descriptionValidationTask.js index a9d6031..b56c1ec 100644 --- a/inventory-server/src/services/ai/tasks/descriptionValidationTask.js +++ b/inventory-server/src/services/ai/tasks/descriptionValidationTask.js @@ -89,16 +89,25 @@ function createDescriptionValidationTask() { ], model: MODELS.LARGE, // openai/gpt-oss-120b - better for content analysis temperature: 0.3, // Slightly higher for creative suggestions - maxTokens: 500, // More tokens for description suggestions + maxTokens: 2000, // Reasoning models need extra tokens for thinking responseFormat: { type: 'json_object' } }); + // Log full raw response for debugging + log.info('[DescriptionValidation] Raw AI response:', { + parsed: response.parsed, + content: response.content, + contentLength: response.content?.length + }); + // Parse the response result = parseDescriptionResponse(response.parsed, response.content); } catch (jsonError) { // If JSON mode failed, check if we have failedGeneration to parse if (jsonError.failedGeneration) { - log.warn('[DescriptionValidation] JSON mode failed, attempting to parse failed_generation'); + log.warn('[DescriptionValidation] JSON mode failed, attempting to parse failed_generation:', { + failedGeneration: jsonError.failedGeneration + }); result = parseDescriptionResponse(null, jsonError.failedGeneration); response = { latencyMs: 0, usage: {}, model: MODELS.LARGE }; } else { @@ -111,9 +120,14 @@ function createDescriptionValidationTask() { ], model: MODELS.LARGE, temperature: 0.3, - maxTokens: 500 + maxTokens: 2000 // Reasoning models need extra tokens for thinking // No responseFormat - let the model respond freely }); + log.info('[DescriptionValidation] Raw AI response (no JSON mode):', { + parsed: response.parsed, + content: response.content, + contentLength: response.content?.length + }); result = parseDescriptionResponse(response.parsed, response.content); } } diff --git a/inventory-server/src/services/ai/tasks/nameValidationTask.js b/inventory-server/src/services/ai/tasks/nameValidationTask.js index 0b13b6e..82f5c8b 100644 --- a/inventory-server/src/services/ai/tasks/nameValidationTask.js +++ b/inventory-server/src/services/ai/tasks/nameValidationTask.js @@ -71,12 +71,26 @@ function createNameValidationTask() { const companyKey = product.company_id || product.company_name || product.company; const prompts = await loadNameValidationPrompts(pool, companyKey); + // Debug: Log loaded prompts + log.info('[NameValidation] Loaded prompts:', { + hasSystem: !!prompts.system, + systemLength: prompts.system?.length || 0, + hasGeneral: !!prompts.general, + generalLength: prompts.general?.length || 0, + generalPreview: prompts.general?.substring(0, 100) || '(empty)', + hasCompanySpecific: !!prompts.companySpecific, + companyKey + }); + // Validate required prompts exist validateRequiredPrompts(prompts, 'name_validation'); // Build the user prompt with database-loaded prompts const userPrompt = buildNameUserPrompt(product, prompts); + // Debug: Log the full user prompt being sent + log.info('[NameValidation] User prompt:', userPrompt.substring(0, 500)); + let response; let result; @@ -87,18 +101,27 @@ function createNameValidationTask() { { role: 'system', content: prompts.system }, { role: 'user', content: userPrompt } ], - model: MODELS.SMALL, // openai/gpt-oss-20b - fast for simple tasks + model: MODELS.SMALL, // openai/gpt-oss-20b - reasoning model temperature: 0.2, // Low temperature for consistent results - maxTokens: 300, + maxTokens: 1500, // Reasoning models need extra tokens for thinking responseFormat: { type: 'json_object' } }); + // Log full raw response for debugging + log.info('[NameValidation] Raw AI response:', { + parsed: response.parsed, + content: response.content, + contentLength: response.content?.length + }); + // Parse the response result = parseNameResponse(response.parsed, response.content); } catch (jsonError) { // If JSON mode failed, check if we have failedGeneration to parse if (jsonError.failedGeneration) { - log.warn('[NameValidation] JSON mode failed, attempting to parse failed_generation'); + log.warn('[NameValidation] JSON mode failed, attempting to parse failed_generation:', { + failedGeneration: jsonError.failedGeneration + }); result = parseNameResponse(null, jsonError.failedGeneration); response = { latencyMs: 0, usage: {}, model: MODELS.SMALL }; } else { @@ -111,9 +134,14 @@ function createNameValidationTask() { ], model: MODELS.SMALL, temperature: 0.2, - maxTokens: 300 + maxTokens: 1500 // Reasoning models need extra tokens for thinking // No responseFormat - let the model respond freely }); + log.info('[NameValidation] Raw AI response (no JSON mode):', { + parsed: response.parsed, + content: response.content, + contentLength: response.content?.length + }); result = parseNameResponse(response.parsed, response.content); } } diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/AiSuggestionBadge.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/AiSuggestionBadge.tsx index 2640823..0b8803a 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/AiSuggestionBadge.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/AiSuggestionBadge.tsx @@ -3,10 +3,20 @@ * * Displays an AI suggestion with accept/dismiss actions. * Used for inline validation suggestions on Name and Description fields. + * + * For description fields, starts collapsed (just icon + count) and expands on click. + * For name fields, uses compact inline mode. */ -import { Check, X, Sparkles, AlertCircle } from 'lucide-react'; +import { useState } from 'react'; +import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; interface AiSuggestionBadgeProps { @@ -20,8 +30,10 @@ interface AiSuggestionBadgeProps { onDismiss: () => void; /** Additional CSS classes */ className?: string; - /** Whether to show the suggestion as compact (inline) or expanded */ + /** Whether to show the suggestion as compact (inline) - used for name field */ compact?: boolean; + /** Whether to start in collapsible mode (icon + count) - used for description field */ + collapsible?: boolean; } export function AiSuggestionBadge({ @@ -30,96 +42,212 @@ export function AiSuggestionBadge({ onAccept, onDismiss, className, - compact = false + compact = false, + collapsible = false }: AiSuggestionBadgeProps) { + const [isExpanded, setIsExpanded] = useState(false); + + // Compact mode for name fields - inline suggestion with accept/dismiss if (compact) { return (
- - +
+ + + {suggestion} +
- - + + + + + + +

Accept suggestion

+
+
+
+ + + + + + +

Ignore

+
+
+
+ {/* Info icon with issues tooltip */} + {issues.length > 0 && ( + + + + + + +
+
+ Issues found: +
+ {issues.map((issue, index) => ( +
+ + {issue} +
+ ))} +
+
+
+
+ )}
); } + // Collapsible mode for description fields + if (collapsible && !isExpanded) { + return ( + + ); + } + + // Expanded view (default for non-compact, or when collapsible is expanded) return (
- {/* Header */} -
- - - AI Suggestion - + {/* Header with collapse button if collapsible */} +
+
+ + + AI Suggestion + + {issues.length > 0 && ( + + ({issues.length} {issues.length === 1 ? 'issue' : 'issues'}) + + )} +
+ {collapsible && ( + + )}
- {/* Suggestion content */} -
- {suggestion} -
- - {/* Issues list (if any) */} + {/* Issues list */} {issues.length > 0 && ( -
+
{issues.map((issue, index) => (
- + {issue}
))}
)} + {/* Suggested description */} +
+
+ Suggested: +
+
+ {suggestion} +
+
+ {/* Actions */}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx index 33cc4ef..325941f 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx @@ -242,7 +242,7 @@ const SearchableTemplateSelect: React.FC = ({ disabled={disabled} className={cn('w-full justify-between overflow-hidden', triggerClassName)} > - {getDisplayText()} + {getDisplayText()} diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx index ec9a3ea..8ed06c6 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx @@ -65,6 +65,8 @@ export const ValidationContainer = ({ // Sanity check dialog state const [sanityCheckDialogOpen, setSanityCheckDialogOpen] = useState(false); + // Debug: skip sanity check toggle (admin:debug only) + const [skipSanityCheck, setSkipSanityCheck] = useState(false); // Handle UPC validation after copy-down operations on supplier/upc fields useCopyDownValidation(); @@ -128,9 +130,8 @@ export const ValidationContainer = ({ } }, [onBack]); - // Trigger sanity check when user clicks Continue - const handleTriggerSanityCheck = useCallback(() => { - // Get current rows and prepare for sanity check + // Build products array for sanity check + const buildProductsForSanityCheck = useCallback((): ProductForSanityCheck[] => { const rows = useValidationStore.getState().rows; const fields = useValidationStore.getState().fields; @@ -145,7 +146,7 @@ export const ValidationContainer = ({ }; // Convert rows to sanity check format - const products: ProductForSanityCheck[] = rows.map((row) => ({ + return rows.map((row) => ({ name: row.name as string | undefined, supplier: row.supplier as string | undefined, supplier_name: getFieldLabel('supplier', row.supplier), @@ -166,11 +167,30 @@ export const ValidationContainer = ({ width: row.width as string | number | undefined, height: row.height as string | number | undefined, })); + }, []); - // Open dialog and run check + // Handle viewing cached sanity check results + const handleViewResults = useCallback(() => { + setSanityCheckDialogOpen(true); + }, []); + + // Handle running a fresh sanity check + const handleRunCheck = useCallback(() => { + const products = buildProductsForSanityCheck(); setSanityCheckDialogOpen(true); sanityCheck.runCheck(products); - }, [sanityCheck]); + }, [sanityCheck, buildProductsForSanityCheck]); + + // Handle proceeding directly to next step (skipping sanity check) + const handleProceedDirect = useCallback(() => { + handleNext(); + }, [handleNext]); + + // Force a new sanity check (refresh button in dialog) + const handleRefreshSanityCheck = useCallback(() => { + const products = buildProductsForSanityCheck(); + sanityCheck.runCheck(products); + }, [sanityCheck, buildProductsForSanityCheck]); // Handle proceeding after sanity check const handleSanityCheckProceed = useCallback(() => { @@ -179,11 +199,11 @@ export const ValidationContainer = ({ handleNext(); }, [handleNext, sanityCheck]); - // Handle going back from sanity check dialog + // Handle going back from sanity check dialog (keeps results cached) const handleSanityCheckGoBack = useCallback(() => { setSanityCheckDialogOpen(false); - sanityCheck.clearResults(); - }, [sanityCheck]); + // Don't clear results - keep them cached for next time + }, []); // Handle scrolling to a specific product from sanity check issue const handleScrollToProduct = useCallback((productIndex: number) => { @@ -232,16 +252,17 @@ export const ValidationContainer = ({ {/* Footer with navigation */} {/* Floating selection bar - appears when rows selected */} @@ -272,7 +293,7 @@ export const ValidationContainer = ({ debugData={aiValidation.debugPrompt} /> - {/* Sanity Check Dialog - auto-triggered on Continue */} + {/* Sanity Check Dialog - shows cached results or runs new check */} {/* Template form dialog - for saving row as template */} diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx index eed4705..7424dc0 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx @@ -1,62 +1,61 @@ /** * ValidationFooter Component * - * Navigation footer with back/next buttons, AI validate, and summary info. - * Triggers sanity check automatically when user clicks "Continue". + * Navigation footer with back/next buttons and summary info. + * After first sanity check, shows options to view results, recheck, or proceed directly. */ -import { useState } from 'react'; +import { useContext } from 'react'; import { Button } from '@/components/ui/button'; -import { CheckCircle, Wand2, FileText, Sparkles } from 'lucide-react'; -import { Protected } from '@/components/auth/Protected'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { CheckCircle, Loader2, Bug, Eye, RefreshCw, ChevronRight } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { AuthContext } from '@/contexts/AuthContext'; interface ValidationFooterProps { onBack?: () => void; - onNext?: () => void; + /** Called to proceed directly to next step (no sanity check) */ + onProceedDirect?: () => void; + /** Called to view cached sanity check results */ + onViewResults?: () => void; + /** Called to run a fresh sanity check */ + onRunCheck?: () => void; canGoBack: boolean; canProceed: boolean; errorCount: number; rowCount: number; - onAiValidate?: () => void; - isAiValidating?: boolean; - onShowDebug?: () => void; - /** Called when user clicks Continue - triggers sanity check */ - onTriggerSanityCheck?: () => void; - /** Whether sanity check is available (Groq enabled) */ - sanityCheckAvailable?: boolean; + /** Whether sanity check is currently running */ + isSanityChecking?: boolean; + /** Whether sanity check has been run at least once */ + hasRunSanityCheck?: boolean; + /** Whether to skip sanity check (debug mode) */ + skipSanityCheck?: boolean; + /** Called when skip sanity check toggle changes */ + onSkipSanityCheckChange?: (skip: boolean) => void; } export const ValidationFooter = ({ onBack, - onNext, + onProceedDirect, + onViewResults, + onRunCheck, canGoBack, canProceed, errorCount, rowCount, - onAiValidate, - isAiValidating = false, - onShowDebug, - onTriggerSanityCheck, - sanityCheckAvailable = false, + isSanityChecking = false, + hasRunSanityCheck = false, + skipSanityCheck = false, + onSkipSanityCheckChange, }: ValidationFooterProps) => { - const [showErrorDialog, setShowErrorDialog] = useState(false); - - // Handle Continue click - either trigger sanity check or proceed directly - const handleContinueClick = () => { - if (canProceed) { - // If sanity check is available, trigger it first - if (sanityCheckAvailable && onTriggerSanityCheck) { - onTriggerSanityCheck(); - } else if (onNext) { - // No sanity check available, proceed directly - onNext(); - } - } else { - // Show error dialog if there are validation errors - setShowErrorDialog(true); - } - }; + const { user } = useContext(AuthContext); + const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes('admin:debug')); return (
@@ -83,80 +82,130 @@ export const ValidationFooter = ({ {/* Action buttons */}
- {/* Show Prompt Debug - Admin only */} - {onShowDebug && ( - - - + {/* Skip sanity check toggle - only for admin:debug users */} + {hasDebugPermission && onSkipSanityCheckChange && ( + + + +
+ + +
+
+ +

Debug: Skip sanity check

+
+
+
)} - {/* AI Validate */} - {onAiValidate && ( + {/* Before first sanity check: single "Continue" button that runs the check */} + {!hasRunSanityCheck && !skipSanityCheck && ( )} - {/* Next button */} - {onNext && ( + {/* After first sanity check: show all three options */} + {hasRunSanityCheck && !skipSanityCheck && ( <> + {/* View previous results */} + + + + + + +

View previous sanity check results

+
+
+
+ + {/* Run fresh check */} + + + + + + +

Run a fresh sanity check

+
+
+
+ + {/* Proceed directly */} - - - - - Are you sure? - - There are still {errorCount} validation error{errorCount !== 1 ? 's' : ''} in your data. - Are you sure you want to continue? - - - - - - - - )} + + {/* Skip mode: just show Continue */} + {skipSanityCheck && ( + + )}
); diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index 2ae9e35..2cd52ad 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -93,7 +93,7 @@ const getCellComponent = (field: Field, optionCount: number = 0) => { /** * Row height for virtualization */ -const ROW_HEIGHT = 40; +const ROW_HEIGHT = 80; // Taller rows to show 2 lines of description + space for AI badges const HEADER_HEIGHT = 40; // Stable empty references to avoid creating new objects in selectors @@ -176,6 +176,19 @@ const CellWrapper = memo(({ const isDismissed = isInlineAiField ? inlineAiSuggestion?.dismissed?.[field.key as 'name' | 'description'] : false; const showSuggestion = fieldSuggestion && !fieldSuggestion.isValid && fieldSuggestion.suggestion && !isDismissed; + // Debug: Log when we have a suggestion for name field + if (isInlineAiField && field.key === 'name' && inlineAiSuggestion) { + console.log('[CellWrapper] Name field suggestion:', { + productIndex, + inlineAiSuggestion, + fieldSuggestion, + isDismissed, + showSuggestion, + isValid: fieldSuggestion?.isValid, + suggestion: fieldSuggestion?.suggestion + }); + } + // Check if cell has a value (for showing copy-down button) const hasValue = value !== undefined && value !== null && value !== ''; @@ -289,8 +302,12 @@ const CellWrapper = memo(({ // Stable callback for onBlur - validates field and triggers UPC validation if needed // Uses setTimeout(0) to defer validation AFTER browser paint const handleBlur = useCallback((newValue: unknown) => { - const { updateCell } = useValidationStore.getState(); - + const state = useValidationStore.getState(); + const { updateCell } = state; + + // Capture previous value BEFORE updating - needed to detect actual changes + const previousValue = state.rows[rowIndex]?.[field.key]; + let valueToSave = newValue; // Auto-correct UPC check digit if this is the UPC field @@ -301,7 +318,10 @@ const CellWrapper = memo(({ // We'll use the corrected value } } - + + // Check if the value actually changed (for AI validation trigger) + const valueChanged = String(valueToSave ?? '') !== String(previousValue ?? ''); + updateCell(rowIndex, field.key, valueToSave); // Defer validation to after the browser paints @@ -534,7 +554,8 @@ const CellWrapper = memo(({ // Trigger inline AI validation for name/description fields // This validates spelling, grammar, and naming conventions using Groq - if (isInlineAiField && valueToSave && String(valueToSave).trim()) { + // Only trigger if value actually changed to avoid unnecessary API calls + if (isInlineAiField && valueChanged && valueToSave && String(valueToSave).trim()) { const currentRow = useValidationStore.getState().rows[rowIndex]; const fields = useValidationStore.getState().fields; if (currentRow) { @@ -554,6 +575,33 @@ const CellWrapper = memo(({ return undefined; }; + // Compute sibling products (same company + line + subline if set) for naming context + const rows = useValidationStore.getState().rows; + const siblingNames: string[] = []; + + if (currentRow.company && currentRow.line) { + const companyId = String(currentRow.company); + const lineId = String(currentRow.line); + const sublineId = currentRow.subline ? String(currentRow.subline) : null; + + for (const row of rows) { + // Skip self + if (row.__index === productIndex) continue; + + // Must match company and line + if (String(row.company) !== companyId) continue; + if (String(row.line) !== lineId) continue; + + // If current product has subline, siblings must match subline too + if (sublineId && String(row.subline) !== sublineId) continue; + + // Add name if it exists + if (row.name && typeof row.name === 'string' && row.name.trim()) { + siblingNames.push(row.name); + } + } + } + // Build product payload for API const productPayload = { name: fieldKey === 'name' ? String(valueToSave) : (currentRow.name as string), @@ -561,7 +609,11 @@ const CellWrapper = memo(({ company_name: currentRow.company ? getFieldLabel('company', currentRow.company) : undefined, company_id: currentRow.company ? String(currentRow.company) : undefined, line_name: currentRow.line ? getFieldLabel('line', currentRow.line) : undefined, + line_id: currentRow.line ? String(currentRow.line) : undefined, + subline_name: currentRow.subline ? getFieldLabel('subline', currentRow.subline) : undefined, + subline_id: currentRow.subline ? String(currentRow.subline) : undefined, categories: currentRow.categories as string | undefined, + siblingNames: siblingNames.length > 0 ? siblingNames : undefined, }; // Call the appropriate API endpoint @@ -691,6 +743,14 @@ const CellWrapper = memo(({ onBlur={handleBlur} onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined} isLoadingOptions={isLoadingOptions} + // Pass AI suggestion props for description field (MultilineInput handles it internally) + {...(field.key === 'description' && { + aiSuggestion: fieldSuggestion, + isAiValidating: isInlineAiValidating, + onDismissAiSuggestion: () => { + useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description'); + }, + })} />
@@ -748,24 +808,24 @@ const CellWrapper = memo(({
)} - {/* Inline AI validation spinner */} - {isInlineAiValidating && isInlineAiField && ( + {/* Inline AI validation spinner - only for name field (description handles it internally) */} + {isInlineAiValidating && field.key === 'name' && (
)} - {/* AI Suggestion badge - shows when AI has a suggestion for this field */} - {showSuggestion && fieldSuggestion && ( + {/* AI Suggestion badge - only for name field (description handles it inside its popover) */} + {showSuggestion && fieldSuggestion && field.key === 'name' && (
{ - useValidationStore.getState().acceptInlineAiSuggestion(productIndex, field.key as 'name' | 'description'); + useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name'); }} onDismiss={() => { - useValidationStore.getState().dismissInlineAiSuggestion(productIndex, field.key as 'name' | 'description'); + useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name'); }} compact /> @@ -881,6 +941,113 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa toast.success('Template applied'); + // Trigger inline AI validation for name/description if template set those fields + const productIndex = currentRow?.__index; + if (productIndex) { + const { setInlineAiValidating, setInlineAiSuggestion } = state; + + // Helper to look up field option label + const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => { + const fieldDef = fields.find(f => f.key === fieldKey); + if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) { + const option = fieldDef.fieldType.options?.find(o => o.value === String(val)); + return option?.label; + } + return undefined; + }; + + // Get the updated row data (after template applied) + const updatedRow = { ...currentRow, ...updates }; + + // Compute sibling names for context + const rows = state.rows; + const siblingNames: string[] = []; + if (updatedRow.company && updatedRow.line) { + const companyId = String(updatedRow.company); + const lineId = String(updatedRow.line); + const sublineId = updatedRow.subline ? String(updatedRow.subline) : null; + + for (const row of rows) { + if (row.__index === productIndex) continue; + if (String(row.company) !== companyId) continue; + if (String(row.line) !== lineId) continue; + if (sublineId && String(row.subline) !== sublineId) continue; + if (row.name && typeof row.name === 'string' && row.name.trim()) { + siblingNames.push(row.name); + } + } + } + + // Trigger name validation if template set name + if (templateFieldsSet.has('name') && updates.name && String(updates.name).trim()) { + setInlineAiValidating(`${productIndex}-name`, true); + + const productPayload = { + name: String(updates.name), + description: updatedRow.description as string, + company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined, + company_id: updatedRow.company ? String(updatedRow.company) : undefined, + line_name: updatedRow.line ? getFieldLabel('line', updatedRow.line) : undefined, + line_id: updatedRow.line ? String(updatedRow.line) : undefined, + subline_name: updatedRow.subline ? getFieldLabel('subline', updatedRow.subline) : undefined, + subline_id: updatedRow.subline ? String(updatedRow.subline) : undefined, + categories: updatedRow.categories as string | undefined, + siblingNames: siblingNames.length > 0 ? siblingNames : undefined, + }; + + fetch('/api/ai/validate/inline/name', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ product: productPayload }), + }) + .then(res => res.json()) + .then(result => { + if (result.success !== false) { + setInlineAiSuggestion(productIndex, 'name', { + isValid: result.isValid ?? true, + suggestion: result.suggestion, + issues: result.issues || [], + latencyMs: result.latencyMs, + }); + } + }) + .catch(err => console.error('[InlineAI] name validation error:', err)) + .finally(() => setInlineAiValidating(`${productIndex}-name`, false)); + } + + // Trigger description validation if template set description + if (templateFieldsSet.has('description') && updates.description && String(updates.description).trim()) { + setInlineAiValidating(`${productIndex}-description`, true); + + const productPayload = { + name: updatedRow.name as string, + description: String(updates.description), + company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined, + company_id: updatedRow.company ? String(updatedRow.company) : undefined, + categories: updatedRow.categories as string | undefined, + }; + + fetch('/api/ai/validate/inline/description', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ product: productPayload }), + }) + .then(res => res.json()) + .then(result => { + if (result.success !== false) { + setInlineAiSuggestion(productIndex, 'description', { + isValid: result.isValid ?? true, + suggestion: result.suggestion, + issues: result.issues || [], + latencyMs: result.latencyMs, + }); + } + }) + .catch(err => console.error('[InlineAI] description validation error:', err)) + .finally(() => setInlineAiValidating(`${productIndex}-description`, false)); + } + } + // Trigger UPC validation if template set supplier or upc, and we have both values const finalSupplier = updates.supplier ?? currentRow?.supplier; const finalUpc = updates.upc ?? currentRow?.upc; @@ -986,6 +1153,8 @@ interface VirtualRowProps { columns: ColumnDef[]; fields: Field[]; totalRowCount: number; + /** Whether table is scrolled horizontally - used for sticky column shadow */ + isScrolledHorizontally: boolean; } const VirtualRow = memo(({ @@ -995,6 +1164,7 @@ const VirtualRow = memo(({ columns, fields, totalRowCount, + isScrolledHorizontally, }: VirtualRowProps) => { // Subscribe to row data - this is THE subscription for all cell values in this row const rowData = useValidationStore( @@ -1044,7 +1214,14 @@ const VirtualRow = memo(({ // Subscribe to inline AI suggestions for this row (for name/description validation) const inlineAiSuggestion = useValidationStore( - useCallback((state) => state.inlineAi.suggestions.get(rowId), [rowId]) + useCallback((state) => { + const suggestion = state.inlineAi.suggestions.get(rowId); + // Debug: Log when subscription returns a value + if (suggestion) { + console.log('[VirtualRow] Got suggestion for rowId:', rowId, suggestion); + } + return suggestion; + }, [rowId]) ); // Check if inline AI validation is running for this row @@ -1065,6 +1242,13 @@ const VirtualRow = memo(({ const hasErrors = Object.keys(rowErrors).length > 0; + // Check if this row has a visible AI suggestion badge (needs higher z-index to show above next row) + // Only name field shows a floating badge - description handles AI suggestions inside its popover + const hasVisibleAiSuggestion = inlineAiSuggestion?.name && + !inlineAiSuggestion.name.isValid && + inlineAiSuggestion.name.suggestion && + !inlineAiSuggestion.dismissed?.name; + // Handle mouse enter for copy-down target selection const handleMouseEnter = useCallback(() => { if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) { @@ -1083,12 +1267,14 @@ const VirtualRow = memo(({ style={{ height: ROW_HEIGHT, transform: `translateY(${virtualStart}px)`, + // Elevate row when it has a visible AI suggestion so badge shows above next row + zIndex: hasVisibleAiSuggestion ? 10 : undefined, }} onMouseEnter={handleMouseEnter} > {/* Selection checkbox cell */}
{ const tableContainerRef = useRef(null); const headerRef = useRef(null); - // Sync header scroll with body scroll + // Calculate name column's natural left position (before it becomes sticky) + // Selection (40) + Template (200) + all field columns before 'name' + const nameColumnLeftOffset = useMemo(() => { + let offset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns + for (const field of fields) { + if (field.key === 'name') break; + offset += field.width || 150; + } + return offset; + }, [fields]); + + // Track horizontal scroll for sticky column shadow + const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false); + + // Sync header scroll with body scroll + track horizontal scroll state const handleScroll = useCallback(() => { if (tableContainerRef.current && headerRef.current) { - headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft; + const scrollLeft = tableContainerRef.current.scrollLeft; + headerRef.current.scrollLeft = scrollLeft; + // Only show shadow when scrolled past the name column's natural position + setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset); } - }, []); + }, [nameColumnLeftOffset]); // Compute filtered indices AND row IDs in a single pass // This avoids calling getState() during render for each row @@ -1373,7 +1590,9 @@ export const ValidationTable = () => { key={column.id || index} className={cn( "px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0", - isNameColumn && "lg:sticky lg:z-20 lg:bg-muted lg:shadow-md" + // Sticky header needs solid background matching the row's bg-muted/50 appearance + isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]", + isNameColumn && isScrolledHorizontally && "lg:shadow-md", )} style={{ width: column.size || 150, @@ -1416,6 +1635,7 @@ export const ValidationTable = () => { columns={columns} fields={fields} totalRowCount={rowCount} + isScrolledHorizontally={isScrolledHorizontally} /> ); })} diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx index 5950c6b..a266dfa 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx @@ -2,6 +2,7 @@ * MultilineInput Component * * Expandable textarea cell for long text content. + * Includes AI suggestion display when available. * Memoized to prevent unnecessary re-renders when parent table updates. */ @@ -15,21 +16,36 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { X, Loader2 } from 'lucide-react'; +import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; import type { Field, SelectOption } from '../../../../types'; import type { ValidationError } from '../../store/types'; +/** AI suggestion data for a single field */ +interface AiFieldSuggestion { + isValid: boolean; + suggestion?: string | null; + issues?: string[]; +} + interface MultilineInputProps { value: unknown; field: Field; options?: SelectOption[]; rowIndex: number; + productIndex: string; isValidating: boolean; errors: ValidationError[]; onChange: (value: unknown) => void; onBlur: (value: unknown) => void; onFetchOptions?: () => void; + isLoadingOptions?: boolean; + /** AI suggestion for this field */ + aiSuggestion?: AiFieldSuggestion | null; + /** Whether AI is currently validating */ + isAiValidating?: boolean; + /** Called when user dismisses/clears the AI suggestion (also called after applying) */ + onDismissAiSuggestion?: () => void; } const MultilineInputComponent = ({ @@ -39,16 +55,38 @@ const MultilineInputComponent = ({ errors, onChange, onBlur, + aiSuggestion, + isAiValidating, + onDismissAiSuggestion, }: MultilineInputProps) => { const [popoverOpen, setPopoverOpen] = useState(false); const [editValue, setEditValue] = useState(''); const [localDisplayValue, setLocalDisplayValue] = useState(null); + const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false); + const [editedSuggestion, setEditedSuggestion] = useState(''); const cellRef = useRef(null); const preventReopenRef = useRef(false); const hasError = errors.length > 0; const errorMessage = errors[0]?.message; + // Check if we have a displayable AI suggestion + const hasAiSuggestion = aiSuggestion && !aiSuggestion.isValid && aiSuggestion.suggestion; + const aiIssues = aiSuggestion?.issues || []; + + // Handle wheel scroll in textarea - stop propagation to prevent table scroll + const handleTextareaWheel = useCallback((e: React.WheelEvent) => { + const target = e.currentTarget; + const { scrollTop, scrollHeight, clientHeight } = target; + const atTop = scrollTop === 0; + const atBottom = scrollTop + clientHeight >= scrollHeight - 1; + + // Only stop propagation if we can scroll in the direction of the wheel + if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) { + e.stopPropagation(); + } + }, []); + // Initialize localDisplayValue on mount and when value changes externally useEffect(() => { const strValue = String(value ?? ''); @@ -57,6 +95,13 @@ const MultilineInputComponent = ({ } }, [value, localDisplayValue]); + // Initialize edited suggestion when AI suggestion changes + useEffect(() => { + if (aiSuggestion?.suggestion) { + setEditedSuggestion(aiSuggestion.suggestion); + } + }, [aiSuggestion?.suggestion]); + // Handle trigger click to toggle the popover const handleTriggerClick = useCallback( (e: React.MouseEvent) => { @@ -91,6 +136,7 @@ const MultilineInputComponent = ({ // Immediately close popover setPopoverOpen(false); + setAiSuggestionExpanded(false); // Prevent reopening preventReopenRef.current = true; @@ -117,6 +163,23 @@ const MultilineInputComponent = ({ setEditValue(e.target.value); }, []); + // Handle accepting the AI suggestion (possibly edited) + const handleAcceptSuggestion = useCallback(() => { + // Use the edited suggestion + setEditValue(editedSuggestion); + setLocalDisplayValue(editedSuggestion); + onChange(editedSuggestion); + onBlur(editedSuggestion); + onDismissAiSuggestion?.(); // Clear the suggestion after accepting + setAiSuggestionExpanded(false); + }, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]); + + // Handle dismissing the AI suggestion + const handleDismissSuggestion = useCallback(() => { + onDismissAiSuggestion?.(); + setAiSuggestionExpanded(false); + }, [onDismissAiSuggestion]); + // Calculate display value const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? ''); @@ -134,14 +197,38 @@ const MultilineInputComponent = ({
{displayValue} + {/* AI suggestion indicator - small badge in corner, clickable to open with AI expanded */} + {hasAiSuggestion && !popoverOpen && ( + + )} + {/* AI validating indicator */} + {isAiValidating && ( +
+ +
+ )}
@@ -158,14 +245,14 @@ const MultilineInputComponent = ({ -
+
+ {/* Close button */} + {/* Main textarea */}