modcomms/frontend/components/WIPReviewer.tsx
michael fd934bbb5f Update frontend UI text to use British English spelling
Change user-facing strings from American to British English: analyze→analyse,
analyzing→analysing, optimized→optimised, color→colour, analyzes→analyses,
synthesizes→synthesises, optimization→optimisation. Code identifiers, status
enums, and developer-facing messages are intentionally unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:11:10 -06:00

338 lines
16 KiB
TypeScript
Executable file

import React, { useState, useRef, useEffect } from 'react';
import { IPublicClientApplication } from '@azure/msal-browser';
import type { DropdownOptions } from '../App';
import { analyzeWIPProof, getWIPChatResponse } from '../services/geminiService';
import type { AgentName } from '../types';
import { UserIcon } from './icons/UserIcon';
import { LeadAgentIcon } from './icons/LeadAgentIcon';
import { PaperClipIcon } from './icons/PaperClipIcon';
import { SendIcon } from './icons/SendIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { DocumentIcon } from './icons/DocumentIcon';
// --- TYPE DEFINITIONS ---
interface TextContent { type: 'text'; text: string; }
interface FilePreviewContent { type: 'file_preview'; fileName: string; previewUrl: string; mimeType: string; }
interface ConfirmationContent { type: 'confirmation'; file: File; }
interface LoadingContent { type: 'loading'; }
interface ErrorContent { type: 'error'; text: string; }
type MessageContent = TextContent | FilePreviewContent | ConfirmationContent | LoadingContent | ErrorContent;
interface Message {
id: string;
sender: 'user' | 'agent';
content: MessageContent;
}
const fileToDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// --- SUB-COMPONENTS ---
const ConfirmationComponent: React.FC<{
dropdownOptions: DropdownOptions;
onConfirm: (details: { channel: string; subChannel: string; proofType: string }) => void;
}> = ({ dropdownOptions, onConfirm }) => {
const [channel, setChannel] = useState('');
const [subChannel, setSubChannel] = useState('');
const [proofType, setProofType] = useState('');
const availableChannels = Object.keys(dropdownOptions.channels);
const availableSubChannels = channel ? Object.keys(dropdownOptions.channels[channel] || {}) : [];
const availableProofTypes = (channel && subChannel) ? (dropdownOptions.channels[channel][subChannel] || []) : [];
const showProofType = availableProofTypes.length > 0;
useEffect(() => {
setSubChannel('');
setProofType('');
}, [channel]);
useEffect(() => {
if (!showProofType) setProofType('');
else setProofType('');
}, [subChannel, showProofType]);
const handleSubmit = () => {
if (channel && subChannel && (!showProofType || proofType)) {
onConfirm({ channel, subChannel, proofType });
}
};
const isSubmitDisabled = !channel || !subChannel || (showProofType && !proofType);
return (
<div className="p-4 bg-white rounded-lg border border-gray-200 shadow-sm space-y-3">
<p className="font-semibold text-gray-800">Please confirm the details for this proof:</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="relative">
<select
value={channel}
onChange={e => setChannel(e.target.value)}
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none"
>
<option value="" disabled>Select Channel</option>
{availableChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
<div className="relative">
<select
value={subChannel}
onChange={e => setSubChannel(e.target.value)}
disabled={!channel}
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none disabled:bg-gray-100"
>
<option value="" disabled>Select Sub-Channel</option>
{availableSubChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
</div>
{showProofType && (
<div className="relative">
<select
value={proofType}
onChange={e => setProofType(e.target.value)}
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none"
>
<option value="" disabled>Select Proof Type</option>
{availableProofTypes.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
</div>
)}
<button
onClick={handleSubmit}
disabled={isSubmitDisabled}
className="w-full bg-active-blue text-white font-bold py-2 px-4 rounded-md hover:bg-primary-blue transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Confirm &amp; Analyse
</button>
</div>
);
};
const MessageBubble: React.FC<{ sender: 'user' | 'agent'; children: React.ReactNode }> = ({ sender, children }) => {
const isUser = sender === 'user';
const bubbleClasses = isUser
? 'bg-active-blue text-white'
: 'bg-white text-gray-800 border border-gray-200';
const alignmentClasses = isUser ? 'items-end' : 'items-start';
const avatar = isUser
? <div className="w-8 h-8 rounded-full bg-primary-blue flex items-center justify-center ring-2 ring-white"><UserIcon className="h-5 w-5 text-white"/></div>
: <div className="w-8 h-8 rounded-full bg-cyan-brand flex items-center justify-center ring-2 ring-white"><LeadAgentIcon className="h-5 w-5 text-primary-blue"/></div>;
const isConfirmation = React.isValidElement(children) && children.type === ConfirmationComponent;
return (
<div className={`flex flex-col ${alignmentClasses} w-full max-w-lg mx-auto`}>
<div className={`flex gap-3 items-end ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
<div className="flex-shrink-0">{avatar}</div>
<div className={
isConfirmation
? "w-full max-w-md"
: `p-3 rounded-lg max-w-xs sm:max-w-md ${bubbleClasses}`
}>
{children}
</div>
</div>
</div>
);
};
// --- MAIN COMPONENT ---
interface WIPReviewerProps {
dropdownOptions: DropdownOptions;
msalInstance: IPublicClientApplication;
}
export const WIPReviewer: React.FC<WIPReviewerProps> = ({ dropdownOptions, msalInstance }) => {
const [messages, setMessages] = useState<Message[]>([
{
id: `msg_${Date.now()}`,
sender: 'agent',
content: { type: 'text', text: "Hello! I'm the Lead Agent. Upload a work-in-progress file for review, or ask me a question about marketing best practices." },
}
]);
const [userInput, setUserInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const addMessage = (sender: 'user' | 'agent', content: MessageContent) => {
setMessages(prev => [...prev, { id: `msg_${Date.now()}`, sender, content }]);
};
const updateLastMessage = (newContent: MessageContent) => {
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1].content = newContent;
return newMessages;
});
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const previewUrl = await fileToDataUrl(file);
addMessage('user', { type: 'file_preview', fileName: file.name, previewUrl, mimeType: file.type });
addMessage('agent', { type: 'confirmation', file });
} catch (error) {
console.error("Error creating file preview:", error);
addMessage('agent', { type: 'error', text: "Sorry, I couldn't load a preview for that file. Please try another file." });
}
// Reset file input
if (event.target) event.target.value = '';
};
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = userInput.trim();
if (!trimmedInput || isLoading) return;
addMessage('user', { type: 'text', text: trimmedInput });
setUserInput('');
setIsLoading(true);
addMessage('agent', { type: 'loading' });
try {
const responseText = await getWIPChatResponse(trimmedInput);
updateLastMessage({ type: 'text', text: responseText });
} catch (err) {
console.error("Chat response failed:", err);
updateLastMessage({ type: 'error', text: "Sorry, I couldn't get a response. Please try again." });
} finally {
setIsLoading(false);
}
};
const handleConfirmAnalysis = async (
messageId: string,
details: { channel: string; subChannel: string; proofType: string },
file: File
) => {
setIsLoading(true);
// Replace confirmation message with loading indicator
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'loading' } } : msg));
try {
const summary = await analyzeWIPProof(file, () => {}, msalInstance);
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'text', text: summary } } : msg));
} catch (err) {
console.error("Analysis failed:", err);
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'error', text: "Sorry, an error occurred during the analysis. Please try again." } } : msg));
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col h-full bg-grey-100">
<header className="p-4 border-b border-gray-200 bg-white/80 backdrop-blur-sm flex-shrink-0">
<h1 className="text-xl font-bold text-primary-blue text-center">WIP Reviewer</h1>
</header>
{/* Message Display Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} sender={msg.sender}>
{(() => {
switch (msg.content.type) {
case 'text':
return <p className="whitespace-pre-wrap">{msg.content.text}</p>;
case 'file_preview':
const { mimeType, previewUrl, fileName } = msg.content;
if (mimeType.startsWith('image/')) {
return <img src={previewUrl} alt={fileName} className="max-w-full h-auto rounded-md object-contain" />;
}
return (
<div className="flex items-center gap-3 p-2 bg-black/20 rounded-md">
<DocumentIcon className="h-8 w-8 text-white"/>
<span className="font-semibold truncate">{fileName}</span>
</div>
);
case 'confirmation':
return <ConfirmationComponent
dropdownOptions={dropdownOptions}
onConfirm={(details) => handleConfirmAnalysis(msg.id, details, msg.content.file)}
/>;
case 'loading':
return (
<div className="flex items-center gap-2 text-gray-600">
<SpinnerIcon className="h-5 w-5 custom-spinner"/>
<span>Thinking...</span>
</div>
);
case 'error':
return <p className="text-red-600 font-medium">{msg.content.text}</p>
default:
return null;
}
})()}
</MessageBubble>
))}
<div ref={messagesEndRef} />
</div>
{/* Message Input Area */}
<div className="flex-shrink-0 p-4 bg-white/80 backdrop-blur-sm border-t border-gray-200">
<div className="max-w-lg mx-auto">
<form onSubmit={handleSendMessage} className="relative flex items-center">
<input type="file" ref={fileInputRef} onChange={handleFileSelect} className="hidden" />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="p-2 text-gray-500 hover:text-active-blue rounded-full transition-colors disabled:opacity-50"
aria-label="Attach file"
>
<PaperClipIcon className="h-6 w-6"/>
</button>
<textarea
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e);
}
}}
className="w-full p-2.5 mx-2 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-active-blue focus:border-active-blue transition"
placeholder="Type your message..."
rows={1}
disabled={isLoading}
/>
<button
type="submit"
className="p-2.5 rounded-full text-white bg-active-blue hover:bg-primary-blue disabled:bg-gray-400 transition-colors"
disabled={!userInput.trim() || isLoading}
aria-label="Send message"
>
<SendIcon className="h-5 w-5" />
</button>
</form>
</div>
</div>
</div>
);
};