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 81cf7dcbab
commit d155b8964a
7 changed files with 594 additions and 81 deletions

View File

@@ -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,
});

View File

@@ -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);
}
}

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>

View File

@@ -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: [] });

View File

@@ -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",
);
});

View File

@@ -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],
);

View File

@@ -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],
);