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:
@@ -7305,6 +7305,7 @@ export function cancelChatResponse(
|
||||
*
|
||||
* Since `EventSource` only supports GET requests, this function uses `fetch()`
|
||||
* with a ReadableStream to parse SSE events from the POST response body.
|
||||
* When attachments are provided, the request body is sent as multipart form data.
|
||||
*/
|
||||
export function streamChatResponse(
|
||||
sessionId: string,
|
||||
@@ -7318,6 +7319,7 @@ export function streamChatResponse(
|
||||
onError?: (data: string) => void;
|
||||
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
||||
},
|
||||
attachments?: File[],
|
||||
projectId?: string,
|
||||
options?: { maxReconnectAttempts?: number },
|
||||
): { close: () => void; isConnected: () => boolean } {
|
||||
@@ -7383,10 +7385,20 @@ export function streamChatResponse(
|
||||
// Start streaming via POST
|
||||
(async () => {
|
||||
try {
|
||||
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
||||
const body = hasAttachments
|
||||
? (() => {
|
||||
const formData = new FormData();
|
||||
formData.append("content", content);
|
||||
attachments.forEach((file) => formData.append("attachments", file));
|
||||
return formData;
|
||||
})()
|
||||
: JSON.stringify({ content });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: withTokenHeader({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ content }),
|
||||
headers: hasAttachments ? withTokenHeader() : withTokenHeader({ "Content-Type": "application/json" }),
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -642,11 +642,18 @@
|
||||
/* Input area */
|
||||
.chat-input-area {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* === Chat Skill Menu === */
|
||||
@@ -778,6 +785,128 @@
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.chat-attach-btn {
|
||||
min-width: calc(var(--space-lg) * 2);
|
||||
min-height: calc(var(--space-lg) * 2);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-input-wrapper--dragover {
|
||||
border: 1px dashed var(--todo);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chat-attachment-previews {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-attachment-preview {
|
||||
width: calc(var(--space-xl) * 3);
|
||||
height: calc(var(--space-xl) * 3);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-attachment-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-attachment-preview-name {
|
||||
font-size: 0.625rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-attachment-remove {
|
||||
position: absolute;
|
||||
top: calc(var(--space-xs) * -1);
|
||||
right: calc(var(--space-xs) * -1);
|
||||
background: color-mix(in srgb, var(--color-error) 80%, transparent);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
width: calc(var(--space-md) * 2);
|
||||
height: calc(var(--space-md) * 2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-attachment-remove:hover {
|
||||
background: color-mix(in srgb, var(--color-error) 90%, transparent);
|
||||
}
|
||||
|
||||
.chat-attachment-remove:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.chat-attachment-remove:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.chat-message-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-message-attachment-link {
|
||||
display: inline-flex;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chat-message-attachment {
|
||||
max-width: calc(var(--space-xl) * 12);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chat-message-attachment-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 50%, transparent);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-message-attachment-file:hover {
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
}
|
||||
|
||||
.chat-message-attachment-file:focus-visible,
|
||||
.chat-message-attachment-link:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
/* === Chat Pending Message === */
|
||||
.chat-pending-message {
|
||||
display: flex;
|
||||
@@ -884,4 +1013,21 @@
|
||||
.chat-tool-calls-group-status {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-attach-btn,
|
||||
.chat-input-send,
|
||||
.chat-input-stop {
|
||||
min-width: calc(var(--space-lg) * 2.25);
|
||||
min-height: calc(var(--space-lg) * 2.25);
|
||||
}
|
||||
|
||||
.chat-attachment-preview {
|
||||
width: calc(var(--space-xl) * 2.5);
|
||||
height: calc(var(--space-xl) * 2.5);
|
||||
}
|
||||
|
||||
.chat-attachment-remove {
|
||||
width: calc(var(--space-lg) * 2.25);
|
||||
height: calc(var(--space-lg) * 2.25);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,6 +21,8 @@ vi.mock("../../hooks/useChat");
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
||||
const mockCreateObjectURL = vi.fn();
|
||||
const mockRevokeObjectURL = vi.fn();
|
||||
|
||||
// Mock lucide-react icons - spread actual module and override specific icons
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
@@ -40,6 +42,8 @@ vi.mock("lucide-react", async (importOriginal) => {
|
||||
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
|
||||
Eye: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye"} {...props} />,
|
||||
EyeOff: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye-off"} {...props} />,
|
||||
Paperclip: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-paperclip"} {...props} />,
|
||||
File: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-file"} {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -170,6 +174,9 @@ function mockViewportMode(mode: "mobile" | "desktop") {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchDiscoveredSkills.mockResolvedValue([]);
|
||||
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
|
||||
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
|
||||
Object.defineProperty(URL, "revokeObjectURL", { value: mockRevokeObjectURL, writable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -878,7 +885,7 @@ describe("ChatView", () => {
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "Hello world{enter}");
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith("Hello world");
|
||||
expect(sendMessage).toHaveBeenCalledWith("Hello world", []);
|
||||
});
|
||||
|
||||
it("does not send on Shift+Enter", async () => {
|
||||
@@ -897,6 +904,131 @@ describe("ChatView", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("attachments", () => {
|
||||
it("clicking paperclip triggers hidden file input", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(fileInput, "click");
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-attach-btn"));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows attaching an image and sends with attachments only", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const attachButton = screen.getByTestId("chat-attach-btn");
|
||||
expect(attachButton).toBeInTheDocument();
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const imageFile = new File(["image"], "shot.png", { type: "image/png" });
|
||||
fireEvent.change(fileInput, { target: { files: [imageFile] } });
|
||||
|
||||
expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument();
|
||||
const sendButton = screen.getByTestId("chat-send-btn");
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(sendButton);
|
||||
expect(sendMessage).toHaveBeenCalledWith("", [imageFile]);
|
||||
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts non-image files and renders filename preview", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const textFile = new File(["hello"], "note.txt", { type: "text/plain" });
|
||||
fireEvent.change(fileInput, { target: { files: [textFile] } });
|
||||
|
||||
expect(await screen.findByText("note.txt")).toBeInTheDocument();
|
||||
expect(mockCreateObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds image attachments from paste events", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
const imageFile = new File(["image"], "paste.png", { type: "image/png" });
|
||||
fireEvent.paste(textarea, { clipboardData: { files: [imageFile] } });
|
||||
|
||||
expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds attachments from drag-and-drop", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const wrapper = document.querySelector(".chat-input-wrapper") as HTMLElement;
|
||||
const textFile = new File(["log"], "drop.log", { type: "text/x-log" });
|
||||
fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } });
|
||||
|
||||
expect(await screen.findByText("drop.log")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes pending attachments and revokes preview urls", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const imageFile = new File(["image"], "shot.png", { type: "image/png" });
|
||||
fireEvent.change(fileInput, { target: { files: [imageFile] } });
|
||||
|
||||
const removeButton = await screen.findByTestId("chat-attachment-remove-0");
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:shot.png");
|
||||
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders message attachments inline as actionable links", () => {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [
|
||||
{
|
||||
id: "msg-attach",
|
||||
sessionId: "session-001",
|
||||
role: "assistant",
|
||||
content: "Attached files",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
attachments: [
|
||||
{
|
||||
id: "att-1",
|
||||
filename: "img-1.png",
|
||||
originalName: "capture.png",
|
||||
mimeType: "image/png",
|
||||
size: 10,
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "att-2",
|
||||
filename: "note.txt",
|
||||
originalName: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 20,
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const links = screen.getAllByTestId("chat-message-attachment");
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/img-1.png");
|
||||
expect(links[0]).toHaveAttribute("target", "_blank");
|
||||
expect(links[1]).toHaveAttribute("href", "/api/chat/sessions/session-001/attachments/note.txt");
|
||||
expect(screen.getByText("note.txt")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent mentions", () => {
|
||||
it("shows mention popup when @ is typed", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
|
||||
@@ -590,6 +590,7 @@ describe("QuickChatFAB", () => {
|
||||
"session-001",
|
||||
"Hello model",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
@@ -680,6 +681,7 @@ describe("QuickChatFAB", () => {
|
||||
"session-model-002",
|
||||
"fresh thread message",
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
@@ -782,6 +784,7 @@ describe("QuickChatFAB", () => {
|
||||
onDone: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
undefined,
|
||||
"proj-123",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,6 +44,14 @@ export interface ChatMessageInfo {
|
||||
content: string;
|
||||
thinkingOutput?: string | null;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -71,7 +79,8 @@ export interface UseChatReturn {
|
||||
deleteSession: (id: string) => Promise<void>;
|
||||
|
||||
// Message operations
|
||||
sendMessage: (content: string) => void;
|
||||
/** Send a message, optionally with file attachments to upload with the prompt. */
|
||||
sendMessage: (content: string, attachments?: File[]) => void;
|
||||
stopStreaming: () => void;
|
||||
clearPendingMessage: () => void;
|
||||
loadMoreMessages: () => Promise<void>;
|
||||
@@ -130,6 +139,7 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
|
||||
content: message.content,
|
||||
thinkingOutput: message.thinkingOutput,
|
||||
toolCalls: extractCompletedToolCalls(message.metadata),
|
||||
attachments: message.attachments,
|
||||
createdAt: message.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -411,7 +421,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
|
||||
// Send a message
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
(content: string, attachments?: File[]) => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (isStreaming) {
|
||||
@@ -556,7 +566,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
},
|
||||
};
|
||||
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, projectId);
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, attachments, projectId);
|
||||
},
|
||||
[activeSession, isStreaming, projectId, refreshSessions],
|
||||
);
|
||||
|
||||
@@ -512,7 +512,7 @@ export function useQuickChat(
|
||||
},
|
||||
};
|
||||
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, projectId);
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, undefined, projectId);
|
||||
},
|
||||
[activeSession, isStreaming, projectId, addToast, reloadMessages],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user