feat(FN-2911): improve chat attachment compose and preview UX

- Add attachment-first chat compose flow with paperclip picker, drag-and-drop, and paste support in ChatView input
- Render pending attachment preview chips with per-file remove actions and drag-over visual affordance
- Display sent message attachments with image/file presentation and accessible focus/interaction styling
- Update ChatView tests and icon mocks to cover attachment-only sends, file selection, and preview behavior

Fusion-Task-Id: FN-2911
This commit is contained in:
Fusion
2026-04-28 23:11:16 -07:00
committed by gsxdsm
parent 83ad6e45ab
commit bdfb08f380
7 changed files with 594 additions and 81 deletions

View File

@@ -16,6 +16,8 @@ import {
Square,
Eye,
EyeOff,
Paperclip,
File,
} from "lucide-react";
import { useChat, type ToolCallInfo } from "../hooks/useChat";
import { useViewportMode } from "./Header";
@@ -141,6 +143,25 @@ const chatMarkdownComponents: Components = {
*/
const FN_AGENT_ID = "__fn_agent__";
interface PendingAttachment {
file: File;
previewUrl: string;
}
const ALLOWED_ATTACHMENT_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"application/json",
"text/yaml",
"text/markdown",
"text/csv",
"application/xml",
"text/x-log",
];
function getSkillTriggerMatch(value: string): { filter: string; start: number; end: number } | null {
const triggerMatch = /(^|[\s])\/([^\s]*)$/.exec(value);
if (!triggerMatch) {
@@ -383,6 +404,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
const [mentionStartPos, setMentionStartPos] = useState(-1);
const [plainTextMessageIds, setPlainTextMessageIds] = useState<Set<string>>(() => new Set());
// Attachment state mirrors QuickEntryBox: pending files selected before send.
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
// File mention state and hook
const [, setFileMentionPopupVisible] = useState(false);
@@ -409,6 +433,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
const mentionCursorPosRef = useRef(0);
const mode = useViewportMode();
const isMobile = mode === "mobile";
@@ -542,6 +568,58 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
};
}, [projectId]);
useEffect(() => {
pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]);
useEffect(() => {
return () => {
for (const attachment of pendingAttachmentsRef.current) {
if (attachment.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
}
}
};
}, []);
const handleAttachmentFiles = useCallback((files: FileList | File[] | null | undefined) => {
if (!files || files.length === 0) return;
const nextAttachments: PendingAttachment[] = [];
for (const file of Array.from(files)) {
if (!ALLOWED_ATTACHMENT_TYPES.includes(file.type)) {
continue;
}
const isImage = file.type.startsWith("image/");
nextAttachments.push({
file,
previewUrl: isImage ? URL.createObjectURL(file) : "",
});
}
if (nextAttachments.length > 0) {
setPendingAttachments((prev) => [...prev, ...nextAttachments]);
}
}, []);
const removeAttachment = useCallback((index: number) => {
setPendingAttachments((prev) => {
const attachment = prev[index];
if (attachment?.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
}
return prev.filter((_, attachmentIndex) => attachmentIndex !== index);
});
}, []);
const handlePaste = useCallback((event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const clipboardFiles = event.clipboardData?.files;
if (!clipboardFiles || clipboardFiles.length === 0) return;
const imageFiles = Array.from(clipboardFiles).filter((file) => file.type.startsWith("image/"));
if (imageFiles.length === 0) return;
handleAttachmentFiles(imageFiles);
}, [handleAttachmentFiles]);
// Handle create session
const handleCreateSession = useCallback(
async (input: { agentId: string; modelProvider?: string; modelId?: string }) => {
@@ -557,18 +635,27 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
[createSession, addToast, isMobile],
);
// Handle send message
// Handle send message including pending attachment uploads.
const handleSend = useCallback(() => {
const trimmed = messageInput.trim();
if (!trimmed || !activeSession) return;
const files = pendingAttachments.map((attachment) => attachment.file);
if ((!trimmed && files.length === 0) || !activeSession) return;
setMessageInput("");
setShowSkillMenu(false);
setSkillFilter("");
setMentionPopupVisible(false);
setMentionFilter("");
setMentionStartPos(-1);
sendMessage(trimmed);
}, [messageInput, activeSession, sendMessage]);
sendMessage(trimmed, files);
setPendingAttachments((prev) => {
for (const attachment of prev) {
if (attachment.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
}
}
return [];
});
}, [messageInput, pendingAttachments, activeSession, sendMessage]);
const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => {
@@ -675,6 +762,67 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
[mentionAgentsByName],
);
const getAttachmentUrl = useCallback(
(filename: string) =>
activeSession ? `/api/chat/sessions/${encodeURIComponent(activeSession.id)}/attachments/${encodeURIComponent(filename)}` : "",
[activeSession],
);
const renderMessageAttachments = useCallback(
(
attachments: Array<{
id: string;
filename: string;
originalName: string;
mimeType: string;
}> | undefined,
) => {
if (!attachments || attachments.length === 0) return null;
return (
<div className="chat-message-attachments">
{attachments.map((attachment) => {
const isImage = attachment.mimeType.startsWith("image/");
const key = attachment.id || attachment.filename;
const href = getAttachmentUrl(attachment.filename);
if (isImage) {
return (
<a
key={key}
className="chat-message-attachment-link"
data-testid="chat-message-attachment"
href={href}
target="_blank"
rel="noopener noreferrer"
>
<img
className="chat-message-attachment"
src={href}
alt={attachment.originalName}
/>
</a>
);
}
return (
<a
key={key}
className="chat-message-attachment-file"
data-testid="chat-message-attachment"
href={href}
target="_blank"
rel="noopener noreferrer"
>
<File size={14} />
<span>{attachment.originalName}</span>
</a>
);
})}
</div>
);
},
[getAttachmentUrl],
);
// Handle input key down
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -1194,6 +1342,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<pre className="chat-message-thinking-content">{message.thinkingOutput}</pre>
</details>
)}
{renderMessageAttachments(message.attachments)}
<div className="chat-message-time">{formatRelativeTime(message.createdAt)}</div>
</div>
);
@@ -1243,6 +1392,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{/* Input */}
{activeSession && (
<div className="chat-input-area">
<input
ref={fileInputRef}
type="file"
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
multiple
style={{ display: "none" }}
onChange={(event) => {
handleAttachmentFiles(event.target.files);
event.target.value = "";
}}
/>
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label="Skill suggestions">
{skillsLoading ? (
@@ -1272,77 +1432,127 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
)}
</div>
)}
<div className="chat-input-wrapper">
<textarea
ref={inputRef}
className="chat-input-textarea"
placeholder="Type a message..."
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
rows={1}
data-testid="chat-input"
/>
<AgentMentionPopup
agents={mentionAgents}
filter={mentionFilter}
highlightedIndex={mentionHighlightIndex}
visible={mentionPopupVisible}
onSelect={handleMentionSelect}
position="below"
/>
<FileMentionPopup
visible={fileMention.mentionActive && !mentionPopupVisible}
position={fileMentionPosition}
files={fileMention.files}
selectedIndex={fileMention.selectedIndex}
onSelect={(file) => {
const newText = fileMention.selectFile(file, messageInput);
setMessageInput(newText);
fileMention.dismissMention();
setFileMentionPopupVisible(false);
inputRef.current?.focus();
}}
loading={fileMention.loading}
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{`Queued: ${pendingPreview}`}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label="Dismiss queued message"
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
{pendingAttachments.length > 0 && (
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
{pendingAttachments.map((attachment, index) => (
<div
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
className="chat-attachment-preview"
data-testid={`chat-attachment-preview-${index}`}
>
×
</button>
</div>
{attachment.previewUrl ? (
<img src={attachment.previewUrl} alt={attachment.file.name} />
) : (
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
)}
<button
type="button"
className="chat-attachment-remove"
onClick={() => removeAttachment(index)}
data-testid={`chat-attachment-remove-${index}`}
aria-label={`Remove ${attachment.file.name}`}
>
×
</button>
</div>
))}
</div>
)}
<div className="chat-input-row">
<button
type="button"
className="btn-icon chat-attach-btn"
data-testid="chat-attach-btn"
aria-label="Attach files"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={16} />
</button>
<div
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragOver(false);
handleAttachmentFiles(event.dataTransfer.files);
}}
>
<textarea
ref={inputRef}
className="chat-input-textarea"
placeholder="Type a message..."
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
rows={1}
data-testid="chat-input"
/>
<AgentMentionPopup
agents={mentionAgents}
filter={mentionFilter}
highlightedIndex={mentionHighlightIndex}
visible={mentionPopupVisible}
onSelect={handleMentionSelect}
position="below"
/>
<FileMentionPopup
visible={fileMention.mentionActive && !mentionPopupVisible}
position={fileMentionPosition}
files={fileMention.files}
selectedIndex={fileMention.selectedIndex}
onSelect={(file) => {
const newText = fileMention.selectFile(file, messageInput);
setMessageInput(newText);
fileMention.dismissMention();
setFileMentionPopupVisible(false);
inputRef.current?.focus();
}}
loading={fileMention.loading}
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{`Queued: ${pendingPreview}`}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label="Dismiss queued message"
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
</div>
)}
</div>
{isStreaming ? (
<button
className="chat-input-stop"
onClick={stopStreaming}
aria-label="Stop generation"
data-testid="chat-stop-btn"
>
<Square size={14} />
</button>
) : (
<button
className="chat-input-send"
onClick={() => void handleSend()}
disabled={!messageInput.trim() && pendingAttachments.length === 0}
data-testid="chat-send-btn"
>
<Send size={16} />
</button>
)}
</div>
{isStreaming ? (
<button
className="chat-input-stop"
onClick={stopStreaming}
aria-label="Stop generation"
data-testid="chat-stop-btn"
>
<Square size={14} />
</button>
) : (
<button
className="chat-input-send"
onClick={() => void handleSend()}
disabled={!messageInput.trim()}
data-testid="chat-send-btn"
>
<Send size={16} />
</button>
)}
</div>
)}
</div>