FN-8502: add photo and file attachments to Chat and Quick Add
Enable supported photo and file attachment workflows across Quick Add and Chat. - Expand Quick Add and Chat picker, paste, and drag/drop attachment intake. - Preserve pending attachments until direct and room sends are accepted, with accessible previews and retry support. - Align Chat MIME and size validation with task attachments, including video, Markdown, and TOML support. - Add regression coverage for attachment previews, delivery behavior, and accepted MIME types. Files changed: .changeset/fn-8502-chat-quick-add-attachments.md | 7 ++ docs/dashboard-guide.md | 5 +- ... quick-entry-attachment-preview-modal.test.tsx} | 50 ++++++-- packages/dashboard/app/components/ChatView.tsx | 80 +++++++++++-- .../dashboard/app/components/InlineCreateCard.tsx | 6 +- .../app/components/PendingAttachmentPreviews.css | 60 ++++++++++ ...ePreviews.tsx => PendingAttachmentPreviews.tsx} | 68 ++++++----- .../app/components/PendingImagePreviews.css | 35 ------ .../dashboard/app/components/QuickEntryBox.tsx | 127 +++++++++++++-------- packages/dashboard/app/components/TaskForm.tsx | 6 +- .../__tests__/ChatView.core-interactions.test.tsx | 61 ++++++++-- .../__tests__/ChatView.mobile-render.test.tsx | 20 +++- .../components/__tests__/ChatView.rooms.test.tsx | 27 ++++- .../components/__tests__/QuickEntryBox.test.tsx | 59 ++++++++-- packages/dashboard/app/hooks/useChat.ts | 41 ++++++- .../src/__tests__/chat-attachment-content.test.ts | 17 +++ packages/dashboard/src/chat-attachment-content.ts | 4 +- .../dashboard/src/routes/chat-attachment-config.ts | 16 +++ .../src/routes/register-chat-room-routes.ts | 9 +- .../dashboard/src/routes/register-chat-routes.ts | 11 +- 20 files changed, 534 insertions(+), 175 deletions(-) Fusion-Task-Id: FN-8502 Fusion-Task-Lineage: 0205187c-7e5b-45f8-a71d-fcfd9fb01e65 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8502-chat-quick-add-attachments.md
Normal file
7
.changeset/fn-8502-chat-quick-add-attachments.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add photo and file attachments to Quick Add and Main Chat.
|
||||
category: feature
|
||||
dev: Quick Add now aligns picker, paste, and drop MIME intake with task-store attachment support.
|
||||
@@ -567,7 +567,7 @@ Create requests never send an explicit `column`. The task store resolves the lan
|
||||
|
||||
Optional workflow steps declared by the active workflow are available from the quick-add action row and the **New Task** dialog's inline quick buttons. For example, the coding workflow's browser verification option appears as a quick drop-down when that workflow is active; each option is seeded from the workflow step's `defaultOn` setting and is sent with the task's `enabledWorkflowSteps` payload at creation time.
|
||||
|
||||
<!-- FNXC:QuickAddAttachments 2026-07-16-00:00: Quick Add attachments use a compact icon-only paperclip while keeping the Attach action and pending image count in accessible labels. The same pending preview/upload path accepts image selection, paste, and direct drag/drop onto the Quick Add box. Compact pending-image thumbnails are accessible open controls that show the full image in a movable/resizable window, which becomes a full-screen sheet on mobile. -->
|
||||
<!-- FNXC:QuickAddAttachments 2026-08-03-00:00: Quick Add's compact paperclip accepts the same task-store-supported photos and files through picker, paste, and drag/drop. Images retain accessible floating previews; non-image files expose a filename and remove action without an empty open control. -->
|
||||
<!-- FNXC:QuickAddPriorityIndicator 2026-07-10-21:45: Quick Add keeps status controls in the bottom action cluster: GitHub tracking sits beside the paperclip attach button, Priority is icon-only with low/down, normal/flag, high/up, urgent/alert glyphs, and Fast is an icon-only lightning button.
|
||||
FNXC:PriorityColorCoding 2026-07-11-00:00: Priority glyphs share urgency colors across Quick Add, the New Task inline row, and task-card badges: low=info/blue, normal=muted, high=warning/amber, urgent=error/red. -->
|
||||
Quick Add and Inline Create model selection include Plan, Executor, Reviewer, and Merger lanes. Each lane can inherit its default or select a task-specific model; Plan, Reviewer, and Merger also provide independent thinking-level overrides.
|
||||
@@ -576,7 +576,7 @@ Quick Add and Inline Create model selection include Plan, Executor, Reviewer, an
|
||||
|
||||
Quick Add **Save** supports a **Start** menu on touch/pen long-press or mouse right-click only when the exact selected workflow has complete runtime metadata: a real non-sentinel id, nonempty ordered columns with unique nonblank ids, and an object `flags` value on every column. It is eligible only for validated `builtin:coding-ideas` or a validated workflow whose first visible column is a hold. Start snapshots that exact definition and id before duplicate confirmation and submits it unchanged; later selection or metadata refreshes cannot alter promotion. A returned create result that is absent, malformed, missing an id/column/workflow id, or mismatched to the submitted workflow is still a successful create-only result. For a matching result, Start moves through the host Board/List move path only to the first later visible working column, skipping intake, hold, and complete columns; no forward target also remains create-only.
|
||||
|
||||
Quick Add image attachments use the paperclip icon button in the action row. Supported image files (`png`, `jpeg`, `gif`, `webp`) can be selected from that control, pasted into the Quick Add input, or dragged onto the Quick Add box; all three paths show compact pending previews before task creation and upload the images to the created task afterward. Select a pending preview to inspect the full image in a movable, resizable window (a full-screen sheet on mobile); close it with Escape or the close control to return to the preview. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips.
|
||||
Quick Add's paperclip accepts supported photos and files: PNG, JPEG, GIF, WebP, MP4, WebM, QuickTime video, plain text, Markdown, JSON, YAML, TOML, CSV, and XML. Select files, paste them into the Quick Add input, or drag them onto the box; pending attachments upload to the newly created task sequentially. Image attachments show compact previews that open in a movable, resizable window (a full-screen sheet on mobile); file attachments show an accessible filename and remove action without an image-open control. Unsupported selections are ignored, and if one upload fails after task creation, the task remains created while Quick Add reports the filenames that need retrying. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips.
|
||||
|
||||
Quick entry, inline quick-create, and the full **New Task** dialog all check for similar active tasks before creating. When possible duplicates exist, the warning lists each match by task description (falling back to title, then “No description”) and lets you open an existing task, cancel, or create anyway with the duplicates acknowledged.
|
||||
|
||||
@@ -652,6 +652,7 @@ Chat view provides project-scoped conversations with agents.
|
||||
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
|
||||
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for a visible direct or room chat after you leave Chat; opening Chat clears it immediately. Task-detail planner Chat replies stay task-local and do not light up the global Chat unread indicator while those sessions are hidden from the common Chat feed.
|
||||
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
|
||||
- Main Chat and Chat Rooms accept the same supported photos and files as Quick Add through the paperclip, clipboard paste, or drag/drop. Pending images retain their preview/open behavior, while non-image files remain removable filename chips; failed sends preserve staged attachments for retry.
|
||||
- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model.
|
||||
- Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked.
|
||||
<!-- FNXC:ChatMessageEdit 2026-07-12-23:20: Document the message-edit affordance and its resume-from-edit ("forget everything after") semantics, including the model-loop-only scope and inline timestamp placement. -->
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PendingImagePreviews } from "../components/PendingImagePreviews";
|
||||
import { PendingAttachmentPreviews } from "../components/PendingAttachmentPreviews";
|
||||
|
||||
const image = {
|
||||
file: new File(["image"], "preview.png", { type: "image/png" }),
|
||||
previewUrl: "blob:preview",
|
||||
};
|
||||
|
||||
describe("pending task image previews", () => {
|
||||
describe("pending task attachment previews", () => {
|
||||
it.each([
|
||||
"quick-entry-preview",
|
||||
"task-form-preview",
|
||||
@@ -17,8 +17,8 @@ describe("pending task image previews", () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
render(
|
||||
<PendingImagePreviews
|
||||
images={[image]}
|
||||
<PendingAttachmentPreviews
|
||||
attachments={[image]}
|
||||
onRemove={onRemove}
|
||||
removeLabel="Remove image"
|
||||
testIdPrefix={testIdPrefix}
|
||||
@@ -44,10 +44,46 @@ describe("pending task image previews", () => {
|
||||
expect(screen.queryByTestId("floating-window-pending-image-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders no preview shells without pending images", () => {
|
||||
it("renders a non-image filename with removal but no image-open button", () => {
|
||||
const onRemove = vi.fn();
|
||||
render(
|
||||
<PendingAttachmentPreviews
|
||||
attachments={[{ file: new File(["note"], "notes.txt", { type: "text/plain" }) }]}
|
||||
onRemove={onRemove}
|
||||
removeLabel="Remove attachment"
|
||||
testIdPrefix="quick-entry-preview"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("quick-entry-preview-file-0")).toHaveTextContent("notes.txt");
|
||||
expect(screen.queryByTestId("quick-entry-preview-open-0")).toBeNull();
|
||||
expect(screen.getByTestId("quick-entry-preview-remove-0")).toHaveAccessibleName("Remove attachment: notes.txt");
|
||||
fireEvent.click(screen.getByTestId("quick-entry-preview-remove-0"));
|
||||
expect(onRemove).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("renders mixed attachments without empty preview controls and disables removals when requested", () => {
|
||||
render(
|
||||
<PendingAttachmentPreviews
|
||||
attachments={[image, { file: new File(["{}"], "data.json", { type: "application/json" }) }]}
|
||||
onRemove={vi.fn()}
|
||||
disabled
|
||||
removeLabel="Remove attachment"
|
||||
testIdPrefix="quick-entry-preview"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("quick-entry-preview-open-0")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("quick-entry-preview-file-1")).toHaveTextContent("data.json");
|
||||
expect(screen.queryByTestId("quick-entry-preview-open-1")).toBeNull();
|
||||
expect(screen.getByTestId("quick-entry-preview-remove-0")).toBeDisabled();
|
||||
expect(screen.getByTestId("quick-entry-preview-remove-1")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders no preview shells without pending attachments", () => {
|
||||
const { container } = render(
|
||||
<PendingImagePreviews
|
||||
images={[]}
|
||||
<PendingAttachmentPreviews
|
||||
attachments={[]}
|
||||
onRemove={vi.fn()}
|
||||
removeLabel="Remove image"
|
||||
testIdPrefix="quick-entry-preview"
|
||||
@@ -204,19 +204,28 @@ interface PendingAttachment {
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatAttachments 2026-08-03-00:00:
|
||||
Chat must offer precisely the task-store attachment MIME set so picker, paste, and drop never stage
|
||||
files that its upload routes reject. Keep this list aligned with CHAT_ALLOWED_MIME_TYPES on the API.
|
||||
*/
|
||||
const ALLOWED_ATTACHMENT_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
"text/yaml",
|
||||
"text/markdown",
|
||||
"text/x-toml",
|
||||
"text/csv",
|
||||
"application/xml",
|
||||
"text/x-log",
|
||||
];
|
||||
const CHAT_ATTACHMENT_ACCEPT = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm,video/quicktime,.txt,.md,.json,.yaml,.yml,.toml,.csv,.xml";
|
||||
|
||||
/**
|
||||
* ChatView's local name for the shared slash-trigger matcher used by both
|
||||
@@ -1650,11 +1659,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
}, []);
|
||||
|
||||
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);
|
||||
/*
|
||||
FNXC:ChatAttachments 2026-08-03-00:00:
|
||||
Chat paste must use the same MIME validation path as picker and drop. Filtering clipboard data
|
||||
to images made supported text files disappear before the authoritative server validation ran.
|
||||
*/
|
||||
handleAttachmentFiles(event.clipboardData?.files);
|
||||
}, [handleAttachmentFiles]);
|
||||
|
||||
// Handle create session
|
||||
@@ -1858,8 +1868,34 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
return;
|
||||
}
|
||||
|
||||
clearComposerState();
|
||||
sendMessage(trimmed, files);
|
||||
const sentFiles = new Set(files);
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-08-03-00:00:
|
||||
Keep direct-chat previews alive until useChat confirms multipart delivery. A direct-session
|
||||
upload fails asynchronously, so clearComposerState() here would revoke the only retryable File
|
||||
references before its stream error handler can report the rejection.
|
||||
*/
|
||||
setMessageInput("");
|
||||
try {
|
||||
sendMessage(trimmed, files, {
|
||||
onDelivered: () => {
|
||||
setPendingAttachments((prev) => {
|
||||
for (const attachment of prev) {
|
||||
if (sentFiles.has(attachment.file) && attachment.previewUrl) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
}
|
||||
return prev.filter((attachment) => !sentFiles.has(attachment.file));
|
||||
});
|
||||
},
|
||||
onFailed: () => {
|
||||
// Do not overwrite text the user entered while the failed request was in flight.
|
||||
setMessageInput((current) => current || trimmed);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setMessageInput(trimmed);
|
||||
}
|
||||
}, [
|
||||
messageInput,
|
||||
pendingAttachments,
|
||||
@@ -1907,12 +1943,32 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
|
||||
roomSendInFlightRef.current = true;
|
||||
const previousInput = messageInput;
|
||||
clearComposerState();
|
||||
const sentFiles = new Set(files);
|
||||
// Clear only the text optimistically. Keeping staged attachments until upload succeeds lets a
|
||||
// rejected room send be retried without silently losing its photo or file.
|
||||
setMessageInput("");
|
||||
|
||||
try {
|
||||
await rooms.sendRoomMessage(trimmed, { files });
|
||||
setPendingAttachments((prev) => {
|
||||
for (const attachment of prev) {
|
||||
if (sentFiles.has(attachment.file) && attachment.previewUrl) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
}
|
||||
return prev.filter((attachment) => !sentFiles.has(attachment.file));
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
|
||||
// The server accepted this turn, so release only the attachments that were dispatched.
|
||||
setPendingAttachments((prev) => {
|
||||
for (const attachment of prev) {
|
||||
if (sentFiles.has(attachment.file) && attachment.previewUrl) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
}
|
||||
return prev.filter((attachment) => !sentFiles.has(attachment.file));
|
||||
});
|
||||
const message = error.message.trim()
|
||||
? error.message
|
||||
: t("chat.messageSentButReplyFailed", "Message sent, but assistant reply failed");
|
||||
@@ -2845,7 +2901,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
data-testid="chat-file-input"
|
||||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||||
accept={CHAT_ATTACHMENT_ACCEPT}
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
@@ -3830,7 +3886,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||||
accept={CHAT_ATTACHMENT_ACCEPT}
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
import { PendingImagePreviews } from "./PendingImagePreviews";
|
||||
import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const STORAGE_KEY = "kb-inline-create-text";
|
||||
@@ -890,8 +890,8 @@ export function InlineCreateCard({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PendingImagePreviews
|
||||
images={pendingImages}
|
||||
<PendingAttachmentPreviews
|
||||
attachments={pendingImages}
|
||||
onRemove={removeImage}
|
||||
disabled={submitting}
|
||||
removeLabel={t("inline.removeImage", "Remove image")}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-08-03-00:00:
|
||||
Images retain the compact thumbnail affordance; non-images use the same shell with a readable,
|
||||
wrapping filename and no empty open target. Token sizing keeps attachment controls usable at the
|
||||
existing mobile breakpoint without introducing a second responsive layout system.
|
||||
*/
|
||||
.pending-image-preview__open {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pending-image-preview__open:focus-visible {
|
||||
outline: var(--focus-ring-width, thin) solid var(--focus-ring, var(--color-primary));
|
||||
outline-offset: calc(var(--space-xs) * -1);
|
||||
}
|
||||
|
||||
.inline-create-preview--file {
|
||||
width: auto;
|
||||
max-width: calc(var(--space-2xl) * 4);
|
||||
height: auto;
|
||||
min-height: var(--space-2xl);
|
||||
}
|
||||
|
||||
.pending-attachment-preview__file {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: var(--space-xs) var(--space-xl) var(--space-xs) var(--space-sm);
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.pending-image-preview__modal-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.pending-image-preview__modal-content img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pending-attachment-preview__file {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import "./PendingImagePreviews.css";
|
||||
import "./PendingAttachmentPreviews.css";
|
||||
|
||||
export interface PendingImagePreviewItem {
|
||||
export interface PendingAttachmentPreviewItem {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
/** Present only for images, which can be opened in the floating image viewer. */
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
interface PendingImagePreviewsProps {
|
||||
images: PendingImagePreviewItem[];
|
||||
interface PendingAttachmentPreviewsProps {
|
||||
attachments: PendingAttachmentPreviewItem[];
|
||||
onRemove: (index: number) => void;
|
||||
disabled?: boolean;
|
||||
removeLabel: string;
|
||||
@@ -16,20 +17,22 @@ interface PendingImagePreviewsProps {
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-07-16-00:00:
|
||||
QuickEntryBox, TaskForm, and InlineCreateCard must expose identical pending-image open and remove controls. Keeping the floating preview here prevents keyboard dismissal, focus restoration, and blob-URL removal behavior from drifting between task-creation surfaces.
|
||||
FNXC:QuickAddAttachments 2026-08-03-00:00:
|
||||
Task creation surfaces share one pending-attachment renderer so photos retain the established
|
||||
floating preview while non-image task-store attachments have only an actionable filename and
|
||||
remove control. Never render an image-open button without a preview URL.
|
||||
*/
|
||||
export function PendingImagePreviews({
|
||||
images,
|
||||
export function PendingAttachmentPreviews({
|
||||
attachments,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
removeLabel,
|
||||
testIdPrefix,
|
||||
}: PendingImagePreviewsProps) {
|
||||
}: PendingAttachmentPreviewsProps) {
|
||||
const [selectedPreviewUrl, setSelectedPreviewUrl] = useState<string | null>(null);
|
||||
const returnFocusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selectedImage = selectedPreviewUrl
|
||||
? images.find((image) => image.previewUrl === selectedPreviewUrl) ?? null
|
||||
? attachments.find((attachment) => attachment.previewUrl === selectedPreviewUrl) ?? null
|
||||
: null;
|
||||
|
||||
const closePreview = useCallback((restoreFocus = true) => {
|
||||
@@ -60,39 +63,48 @@ export function PendingImagePreviews({
|
||||
setSelectedPreviewUrl(previewUrl);
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback((index: number, previewUrl: string) => {
|
||||
if (selectedPreviewUrl === previewUrl) {
|
||||
const handleRemove = useCallback((index: number, previewUrl?: string) => {
|
||||
if (previewUrl && selectedPreviewUrl === previewUrl) {
|
||||
closePreview(false);
|
||||
}
|
||||
onRemove(index);
|
||||
}, [closePreview, onRemove, selectedPreviewUrl]);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="inline-create-previews">
|
||||
{images.map((image, index) => (
|
||||
<div key={image.previewUrl} className="inline-create-preview">
|
||||
<button
|
||||
type="button"
|
||||
className="pending-image-preview__open"
|
||||
onClick={(event) => openPreview(image.previewUrl, event.currentTarget)}
|
||||
aria-label={`Open image ${image.file.name}`}
|
||||
data-testid={`${testIdPrefix}-open-${index}`}
|
||||
>
|
||||
<img src={image.previewUrl} alt="" />
|
||||
</button>
|
||||
{attachments.map((attachment, index) => (
|
||||
<div
|
||||
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
|
||||
className={`inline-create-preview${attachment.previewUrl ? "" : " inline-create-preview--file"}`}
|
||||
>
|
||||
{attachment.previewUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pending-image-preview__open"
|
||||
onClick={(event) => openPreview(attachment.previewUrl!, event.currentTarget)}
|
||||
aria-label={`Open image ${attachment.file.name}`}
|
||||
data-testid={`${testIdPrefix}-open-${index}`}
|
||||
>
|
||||
<img src={attachment.previewUrl} alt="" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="pending-attachment-preview__file" data-testid={`${testIdPrefix}-file-${index}`}>
|
||||
{attachment.file.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRemove(index, image.previewUrl);
|
||||
handleRemove(index, attachment.previewUrl);
|
||||
}}
|
||||
disabled={disabled}
|
||||
title={removeLabel}
|
||||
aria-label={removeLabel}
|
||||
aria-label={`${removeLabel}: ${attachment.file.name}`}
|
||||
data-testid={`${testIdPrefix}-remove-${index}`}
|
||||
>
|
||||
×
|
||||
@@ -100,7 +112,7 @@ export function PendingImagePreviews({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedImage && (
|
||||
{selectedImage?.previewUrl && (
|
||||
<FloatingWindow
|
||||
title={selectedImage.file.name}
|
||||
onClose={closePreview}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-07-16-00:00:
|
||||
The open button fills the compact shared thumbnail shell while the remove button remains an independent layered control. The preview image deliberately retains its natural dimensions in the resizable FloatingWindow body, whose scroll area is governed by the user-selected window size.
|
||||
*/
|
||||
.pending-image-preview__open {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pending-image-preview__open:focus-visible {
|
||||
outline: var(--focus-ring-width, thin) solid var(--focus-ring, var(--color-primary));
|
||||
outline-offset: calc(var(--space-xs) * -1);
|
||||
}
|
||||
|
||||
.pending-image-preview__modal-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.pending-image-preview__modal-content img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
@@ -17,16 +17,32 @@ import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { PendingImagePreviews } from "./PendingImagePreviews";
|
||||
import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
|
||||
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
|
||||
import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const ALLOWED_TASK_ATTACHMENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
"text/yaml",
|
||||
"text/x-toml",
|
||||
"text/csv",
|
||||
"application/xml",
|
||||
]);
|
||||
const TASK_ATTACHMENT_ACCEPT = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm,video/quicktime,.txt,.md,.json,.yaml,.yml,.toml,.csv,.xml";
|
||||
|
||||
interface PendingImage {
|
||||
interface PendingAttachment {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
@@ -165,8 +181,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
const [startMenuPosition, setStartMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const justResetRef = useRef(false);
|
||||
const previousProjectIdRef = useRef(projectId);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const pendingImagesRef = useRef<PendingImage[]>([]);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
@@ -484,6 +500,17 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
previousProjectIdRef.current = projectId;
|
||||
setAgents([]);
|
||||
setAgentsProjectId(undefined);
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-08-03-00:00:
|
||||
Pending files belong to the project where they were selected. Clear them on a project switch so
|
||||
an attachment cannot be uploaded into the next project's newly created task, and release image URLs.
|
||||
*/
|
||||
setPendingAttachments((pending) => {
|
||||
for (const attachment of pending) {
|
||||
if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setAgentPickerPosition(null);
|
||||
@@ -497,13 +524,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pendingImagesRef.current = pendingImages;
|
||||
}, [pendingImages]);
|
||||
pendingAttachmentsRef.current = pendingAttachments;
|
||||
}, [pendingAttachments]);
|
||||
|
||||
// Cleanup image preview URLs on unmount
|
||||
// Clean up image preview URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingImagesRef.current.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
pendingAttachmentsRef.current.forEach((attachment) => { if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); });
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -628,8 +655,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
}, [showWorkflowPicker]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
pendingAttachments.forEach((attachment) => { if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); });
|
||||
setPendingAttachments([]);
|
||||
dragDepthRef.current = 0;
|
||||
setIsFileDragOver(false);
|
||||
if (fileInputRef.current) {
|
||||
@@ -676,21 +703,22 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
if (typeof window !== "undefined") {
|
||||
removeScopedItem(STORAGE_KEY, projectId);
|
||||
}
|
||||
}, [pendingImages, projectId, optionalSteps]);
|
||||
}, [pendingAttachments, projectId, optionalSteps]);
|
||||
|
||||
const handleImageFiles = useCallback((files: FileList | File[] | null | undefined) => {
|
||||
const handleAttachmentFiles = useCallback((files: FileList | File[] | null | undefined) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages: PendingImage[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
newImages.push({ file, previewUrl: URL.createObjectURL(file) });
|
||||
}
|
||||
const newAttachments: PendingAttachment[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!ALLOWED_TASK_ATTACHMENT_TYPES.has(file.type)) continue;
|
||||
newAttachments.push({
|
||||
file,
|
||||
previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
setPendingImages((prev) => [...prev, ...newImages]);
|
||||
if (newAttachments.length > 0) {
|
||||
setPendingAttachments((prev) => [...prev, ...newAttachments]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -700,12 +728,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-06-30-00:00:
|
||||
Quick Add uses an icon-only paperclip to keep the action row compact, so the accessible name and title carry the Attach action plus pending-image count. The same image intake path handles file input, paste, and file drag/drop so previews and post-create uploads stay consistent.
|
||||
FNXC:QuickAddAttachments 2026-08-03-00:00:
|
||||
Quick Add must accept exactly the task-store attachment MIME set through picker, paste, and drop.
|
||||
Only images receive object URLs and preview controls; all other accepted files remain uploadable file chips.
|
||||
*/
|
||||
const attachLabel = pendingImages.length > 0
|
||||
? t("tasks.attachImagesCount", "Attach images ({{count}} pending)", { count: pendingImages.length })
|
||||
: t("tasks.attachImages", "Attach images");
|
||||
const attachLabel = pendingAttachments.length > 0
|
||||
? t("tasks.attachFilesCount", "Attach photos or files ({{count}} pending)", { count: pendingAttachments.length })
|
||||
: t("tasks.attachFiles", "Attach photos or files");
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDrag(e.dataTransfer)) return;
|
||||
@@ -740,19 +769,19 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
e.preventDefault();
|
||||
clearFileDragState();
|
||||
if (isSubmitting) return;
|
||||
handleImageFiles(e.dataTransfer.files);
|
||||
}, [clearFileDragState, handleImageFiles, isFileDrag, isSubmitting]);
|
||||
handleAttachmentFiles(e.dataTransfer.files);
|
||||
}, [clearFileDragState, handleAttachmentFiles, isFileDrag, isSubmitting]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isSubmitting) return;
|
||||
handleImageFiles(e.clipboardData?.files);
|
||||
}, [handleImageFiles, isSubmitting]);
|
||||
handleAttachmentFiles(e.clipboardData?.files);
|
||||
}, [handleAttachmentFiles, isSubmitting]);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setPendingImages((prev) => {
|
||||
const removeAttachment = useCallback((index: number) => {
|
||||
setPendingAttachments((prev) => {
|
||||
const removed = prev[index];
|
||||
if (removed) {
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
if (removed.previewUrl) URL.revokeObjectURL(removed.previewUrl);
|
||||
}
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
@@ -823,13 +852,13 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
}
|
||||
}
|
||||
}
|
||||
if (createdTask && typeof createdTask.id === "string" && createdTask.id.trim() && pendingImages.length > 0) {
|
||||
if (createdTask && typeof createdTask.id === "string" && createdTask.id.trim() && pendingAttachments.length > 0) {
|
||||
const failures: string[] = [];
|
||||
for (const pendingImage of pendingImages) {
|
||||
for (const pendingAttachment of pendingAttachments) {
|
||||
try {
|
||||
await uploadAttachment(createdTask.id, pendingImage.file, projectId);
|
||||
await uploadAttachment(createdTask.id, pendingAttachment.file, projectId);
|
||||
} catch {
|
||||
failures.push(pendingImage.file.name);
|
||||
failures.push(pendingAttachment.file.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,7 +900,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
sessionAdvisorOverride,
|
||||
priority,
|
||||
effectiveNodeId,
|
||||
pendingImages,
|
||||
pendingAttachments,
|
||||
projectId,
|
||||
addToast,
|
||||
resetForm,
|
||||
@@ -1821,7 +1850,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
{isFileDragOver && (
|
||||
<div className="quick-entry-drop-target" data-testid="quick-entry-drop-target" aria-hidden="true">
|
||||
<Paperclip size={16} aria-hidden="true" />
|
||||
<span>{t("tasks.dropImagesToAttach", "Drop images to attach")}</span>
|
||||
<span>{t("tasks.dropFilesToAttach", "Drop photos or files to attach")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="description-with-refine">
|
||||
@@ -2336,8 +2365,8 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
title={attachLabel}
|
||||
>
|
||||
<Paperclip size={12} aria-hidden="true" />
|
||||
{pendingImages.length > 0 && (
|
||||
<span className="quick-entry-attach-count" aria-hidden="true">{pendingImages.length}</span>
|
||||
{pendingAttachments.length > 0 && (
|
||||
<span className="quick-entry-attach-count" aria-hidden="true">{pendingAttachments.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -2499,11 +2528,11 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PendingImagePreviews
|
||||
images={pendingImages}
|
||||
onRemove={removeImage}
|
||||
<PendingAttachmentPreviews
|
||||
attachments={pendingAttachments}
|
||||
onRemove={removeAttachment}
|
||||
disabled={isSubmitting}
|
||||
removeLabel={t("tasks.removeImage", "Remove image")}
|
||||
removeLabel={t("tasks.removeAttachment", "Remove image")}
|
||||
testIdPrefix="quick-entry-preview"
|
||||
/>
|
||||
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
|
||||
@@ -2643,11 +2672,11 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={TASK_ATTACHMENT_ACCEPT}
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
handleImageFiles(e.target.files);
|
||||
handleAttachmentFiles(e.target.files);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
data-testid="quick-entry-file-input"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTra
|
||||
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { PendingImagePreviews } from "./PendingImagePreviews";
|
||||
import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string {
|
||||
if (status === "online") return t("taskForm.nodeStatusOnline", "Online");
|
||||
@@ -1201,8 +1201,8 @@ export function TaskForm({
|
||||
{/* Attachments */}
|
||||
<div className="form-group">
|
||||
<label>{t("taskForm.attachmentsLabel", "Attachments")}</label>
|
||||
<PendingImagePreviews
|
||||
images={pendingImages}
|
||||
<PendingAttachmentPreviews
|
||||
attachments={pendingImages}
|
||||
onRemove={removeImage}
|
||||
disabled={disabled}
|
||||
removeLabel={t("taskForm.removeImage", "Remove image")}
|
||||
|
||||
@@ -151,8 +151,10 @@ describe("ChatView core interactions", () => {
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows attaching an image and sends with attachments only", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
it("allows attaching an image and clears it after attachment-only delivery", async () => {
|
||||
const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onDelivered?: () => void }) => {
|
||||
callbacks?.onDelivered?.();
|
||||
});
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
@@ -168,8 +170,29 @@ describe("ChatView core interactions", () => {
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(sendButton);
|
||||
expect(sendMessage).toHaveBeenCalledWith("", [imageFile]);
|
||||
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
||||
expect(sendMessage).toHaveBeenCalledWith("", [imageFile], expect.objectContaining({
|
||||
onDelivered: expect.any(Function),
|
||||
onFailed: expect.any(Function),
|
||||
}));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("chat-attachment-previews")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("retains direct-chat attachments after a failed upload for retry", async () => {
|
||||
const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onFailed?: () => void }) => {
|
||||
callbacks?.onFailed?.();
|
||||
});
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [], sendMessage });
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const textFile = new File(["retry"], "retry.txt", { type: "text/plain" });
|
||||
fireEvent.change(fileInput, { target: { files: [textFile] } });
|
||||
await userEvent.click(screen.getByTestId("chat-send-btn"));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith("", [textFile], expect.any(Object));
|
||||
expect(await screen.findByText("retry.txt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts non-image files and renders filename preview", async () => {
|
||||
@@ -184,26 +207,46 @@ describe("ChatView core interactions", () => {
|
||||
expect(mockCreateObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds image attachments from paste events", async () => {
|
||||
it("accepts the task attachment MIME set and rejects server-unsupported logs", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const video = new File(["video"], "demo.mp4", { type: "video/mp4" });
|
||||
const toml = new File(["key = 'value'"], "config.toml", { type: "text/x-toml" });
|
||||
const unsupportedLog = new File(["log"], "server.log", { type: "text/x-log" });
|
||||
fireEvent.change(fileInput, { target: { files: [video, toml, unsupportedLog] } });
|
||||
|
||||
expect(fileInput).toHaveAttribute("accept", expect.stringContaining("video/mp4"));
|
||||
expect(fileInput).toHaveAttribute("accept", expect.stringContaining(".toml"));
|
||||
expect(await screen.findByText("demo.mp4")).toBeInTheDocument();
|
||||
expect(screen.getByText("config.toml")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server.log")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts mixed image and supported text attachments from paste events", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
await renderWithAct(<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] } });
|
||||
const textFile = new File(["note"], "paste.txt", { type: "text/plain" });
|
||||
fireEvent.paste(textarea, { clipboardData: { files: [imageFile, textFile] } });
|
||||
|
||||
expect(await screen.findByTestId("chat-attachment-previews")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-attachment-preview-0").querySelector("img")).toBeTruthy();
|
||||
expect(screen.getByText("paste.txt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds attachments from drag-and-drop", async () => {
|
||||
it("adds supported text attachments from drag-and-drop", async () => {
|
||||
setupMockChat({ activeSession: activeSessionFixture, messages: [] });
|
||||
await renderWithAct(<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" });
|
||||
const textFile = new File(["log"], "drop.txt", { type: "text/plain" });
|
||||
fireEvent.drop(wrapper, { dataTransfer: { files: [textFile] } });
|
||||
|
||||
expect(await screen.findByText("drop.log")).toBeInTheDocument();
|
||||
expect(await screen.findByText("drop.txt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes pending attachments and revokes preview urls", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ChatView } from "../ChatView";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
@@ -200,6 +200,24 @@ describe("FN-5997 mobile chat message pane rendering", () => {
|
||||
setupRooms();
|
||||
});
|
||||
|
||||
it("keeps the direct mobile composer file-capable for a supported text attachment", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("mobile");
|
||||
try {
|
||||
setupChat({ sessions: [activeSession], filteredSessions: [activeSession], activeSession });
|
||||
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const attachButton = screen.getByTestId("chat-attach-btn");
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [new File(["note"], "mobile.txt", { type: "text/plain" })] } });
|
||||
|
||||
expect(attachButton).toBeInTheDocument();
|
||||
expect(screen.getByText("mobile.txt")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-attachment-remove-0")).toHaveAccessibleName("Remove mobile.txt");
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets the direct-thread mobile empty states span the message pane without mobile card chrome while preserving other states", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("mobile");
|
||||
try {
|
||||
|
||||
@@ -553,6 +553,23 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("POST failed", "error");
|
||||
});
|
||||
|
||||
it("preserves staged room attachments when the upload request fails", async () => {
|
||||
const sendRoomMessage = vi.fn().mockRejectedValueOnce(new Error("POST failed"));
|
||||
setup({}, { sendRoomMessage, activeRoom: roomA });
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||
|
||||
const file = new File(["note"], "retry.txt", { type: "text/plain" });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await userEvent.type(textarea, "Retry attachment{enter}");
|
||||
|
||||
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledWith("Retry attachment", { files: [file] }));
|
||||
await waitFor(() => expect(textarea.value).toBe("Retry attachment"));
|
||||
expect(screen.getByText("retry.txt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears room composer on Enter when room send succeeds", async () => {
|
||||
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
|
||||
setup({}, { sendRoomMessage, activeRoom: roomA });
|
||||
@@ -857,7 +874,10 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
|
||||
expect(sendMessage).toHaveBeenCalledWith("Direct second", []);
|
||||
expect(sendMessage).toHaveBeenCalledWith("Direct second", [], expect.objectContaining({
|
||||
onDelivered: expect.any(Function),
|
||||
onFailed: expect.any(Function),
|
||||
}));
|
||||
expect(sendRoomMessage).toHaveBeenCalledTimes(1);
|
||||
mediaSpy.mockRestore();
|
||||
});
|
||||
@@ -1232,7 +1252,10 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "Direct hello{enter}");
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith("Direct hello", []);
|
||||
expect(sendMessage).toHaveBeenCalledWith("Direct hello", [], expect.objectContaining({
|
||||
onDelivered: expect.any(Function),
|
||||
onFailed: expect.any(Function),
|
||||
}));
|
||||
expect(sendRoomMessage).not.toHaveBeenCalled();
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3450,15 +3450,15 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("image attachments", () => {
|
||||
describe("task attachments", () => {
|
||||
it("shows an icon-only Attach control when expanded", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
const attachButton = screen.getByTestId("quick-entry-attach");
|
||||
expect(attachButton).toBeInTheDocument();
|
||||
expect(attachButton).toHaveAccessibleName("Attach images");
|
||||
expect(attachButton).toHaveAttribute("title", "Attach images");
|
||||
expect(attachButton).toHaveAccessibleName("Attach photos or files");
|
||||
expect(attachButton).toHaveAttribute("title", "Attach photos or files");
|
||||
expect(attachButton.textContent).not.toContain("Attach");
|
||||
expect(attachButton.querySelector("svg")).toBeTruthy();
|
||||
expect(attachButton.classList.contains("btn-icon")).toBe(true);
|
||||
@@ -3476,7 +3476,7 @@ describe("QuickEntryBox", () => {
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a preview when an image is pasted", () => {
|
||||
it("adds an image preview when an image is pasted", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
@@ -3552,8 +3552,8 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
const attachButton = screen.getByTestId("quick-entry-attach");
|
||||
expect(attachButton).toHaveAccessibleName("Attach images (1 pending)");
|
||||
expect(attachButton).toHaveAttribute("title", "Attach images (1 pending)");
|
||||
expect(attachButton).toHaveAccessibleName("Attach photos or files (1 pending)");
|
||||
expect(attachButton).toHaveAttribute("title", "Attach photos or files (1 pending)");
|
||||
expect(attachButton.textContent).toBe("1");
|
||||
expect(attachButton.textContent).not.toContain("Attach");
|
||||
});
|
||||
@@ -3581,7 +3581,7 @@ describe("QuickEntryBox", () => {
|
||||
expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull();
|
||||
|
||||
fireEvent.dragEnter(box, { dataTransfer: { types: ["Files"], files: [] } });
|
||||
expect(screen.getByTestId("quick-entry-drop-target")).toHaveTextContent("Drop images to attach");
|
||||
expect(screen.getByTestId("quick-entry-drop-target")).toHaveTextContent("Drop photos or files to attach");
|
||||
expect(box.classList.contains("quick-entry-box--drag-over")).toBe(true);
|
||||
|
||||
fireEvent.dragLeave(box, { dataTransfer: { types: ["Files"], files: [] } });
|
||||
@@ -3605,7 +3605,7 @@ describe("QuickEntryBox", () => {
|
||||
expect(screen.getByTestId("quick-entry-drop-target")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds previews for supported dropped images and rejects unsupported dropped files", () => {
|
||||
it("accepts mixed supported drops as an image preview and accessible file chip", () => {
|
||||
renderQuickEntryBox({});
|
||||
const box = screen.getByTestId("quick-entry-box");
|
||||
const image = new File(["image"], "dropped.png", { type: "image/png" });
|
||||
@@ -3616,7 +3616,48 @@ describe("QuickEntryBox", () => {
|
||||
|
||||
expect(screen.queryByTestId("quick-entry-drop-target")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Open image dropped.png" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Open image notes.txt" })).toBeNull();
|
||||
expect(screen.getByTestId("quick-entry-preview-file-1")).toHaveTextContent("notes.txt");
|
||||
expect(screen.queryByTestId("quick-entry-preview-open-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts supported picker and paste files, preserves duplicate names, and ignores unsupported MIME types", () => {
|
||||
renderQuickEntryBox({});
|
||||
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const firstJson = new File(["{}"], "data.json", { type: "application/json" });
|
||||
const duplicateJson = new File(["{}"], "data.json", { type: "application/json" });
|
||||
const unsupported = new File(["binary"], "archive.bin", { type: "application/octet-stream" });
|
||||
|
||||
expect(fileInput).toHaveAttribute("accept", expect.stringContaining("video/mp4"));
|
||||
fireEvent.change(fileInput, { target: { files: [firstJson, unsupported] } });
|
||||
fireEvent.paste(textarea, { clipboardData: { files: [duplicateJson] } });
|
||||
fireEvent.change(fileInput, { target: { files: [] } });
|
||||
|
||||
expect(screen.getByTestId("quick-entry-preview-file-0")).toHaveTextContent("data.json");
|
||||
expect(screen.getByTestId("quick-entry-preview-file-1")).toHaveTextContent("data.json");
|
||||
expect(screen.queryByText("archive.bin")).toBeNull();
|
||||
expect(screen.getByTestId("quick-entry-attach")).toHaveAccessibleName("Attach photos or files (2 pending)");
|
||||
});
|
||||
|
||||
it("uploads supported mixed attachments sequentially and reports partial failures after creation", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
const addToast = vi.fn();
|
||||
const photo = new File(["photo"], "photo.png", { type: "image/png" });
|
||||
const note = new File(["note"], "note.txt", { type: "text/plain" });
|
||||
vi.mocked(uploadAttachment)
|
||||
.mockResolvedValueOnce({} as any)
|
||||
.mockRejectedValueOnce(new Error("upload failed"));
|
||||
renderQuickEntryBox({ onCreate, addToast });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const fileInput = screen.getByTestId("quick-entry-file-input");
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [photo, note] } });
|
||||
fireEvent.change(textarea, { target: { value: "Create with mixed attachments" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(uploadAttachment).toHaveBeenCalledTimes(2));
|
||||
expect(uploadAttachment.mock.calls.map((call) => call[1])).toEqual([photo, note]);
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to upload: note.txt", "error");
|
||||
});
|
||||
|
||||
it("uploads images added by dropping files after task creation", async () => {
|
||||
|
||||
@@ -136,8 +136,15 @@ export interface UseChatReturn {
|
||||
deleteSession: (id: string) => Promise<void>;
|
||||
|
||||
// Message operations
|
||||
/** Send a message, optionally with file attachments to upload with the prompt. */
|
||||
sendMessage: (content: string, attachments?: File[]) => void;
|
||||
/**
|
||||
* Send a message, optionally with file attachments to upload with the prompt. Attachment
|
||||
* callbacks distinguish a rejected upload from a server-accepted turn whose reply later fails.
|
||||
*/
|
||||
sendMessage: (
|
||||
content: string,
|
||||
attachments?: File[],
|
||||
callbacks?: { onDelivered?: () => void; onFailed?: () => void },
|
||||
) => void;
|
||||
/**
|
||||
* FNXC:ChatMessageEdit 2026-07-07-09:00:
|
||||
* Edit an earlier user message: truncates local + persisted history from that message onward
|
||||
@@ -1263,7 +1270,11 @@ export function useChat(
|
||||
* @param content Message text content to send.
|
||||
* @param attachments Optional files to upload with the message in the same request.
|
||||
*/
|
||||
const sendMessageRef = useRef<(content: string, attachments?: File[]) => void>(() => {
|
||||
const sendMessageRef = useRef<(
|
||||
content: string,
|
||||
attachments?: File[],
|
||||
callbacks?: { onDelivered?: () => void; onFailed?: () => void },
|
||||
) => void>(() => {
|
||||
// no-op until sendMessage is defined
|
||||
});
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
@@ -1306,8 +1317,15 @@ export function useChat(
|
||||
}, [attachIfGenerating, loadMessages, projectId, refreshSessions]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string, attachments?: File[]) => {
|
||||
if (!activeSession) return;
|
||||
(
|
||||
content: string,
|
||||
attachments?: File[],
|
||||
callbacks?: { onDelivered?: () => void; onFailed?: () => void },
|
||||
) => {
|
||||
if (!activeSession) {
|
||||
callbacks?.onFailed?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStreamingRef.current) {
|
||||
const trimmedContent = content.trim();
|
||||
@@ -1396,6 +1414,7 @@ export function useChat(
|
||||
isStreamingRef.current = false;
|
||||
streamRef.current = null;
|
||||
lastAttachedGenerationRef.current = null;
|
||||
callbacks?.onDelivered?.();
|
||||
|
||||
// Clean up tracked ID after a short delay (SSE event should arrive quickly)
|
||||
setTimeout(() => {
|
||||
@@ -1412,6 +1431,18 @@ export function useChat(
|
||||
const shouldSuppressSuspensionError = isLikelyTabSuspensionError(suspensionMessage);
|
||||
const acceptedByServer = meta?.requestAccepted === true;
|
||||
|
||||
/*
|
||||
FNXC:ChatAttachments 2026-08-03-00:00:
|
||||
A direct composer owns its staged File objects and preview URLs until the server accepts
|
||||
the multipart turn. Tell it to retain those files on pre-delivery/upload failure, but
|
||||
release them after an accepted turn even when the provider cannot produce a reply.
|
||||
*/
|
||||
if (acceptedByServer) {
|
||||
callbacks?.onDelivered?.();
|
||||
} else {
|
||||
callbacks?.onFailed?.();
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatReliability 2026-07-01-00:00:
|
||||
Provider errors can arrive after ChatManager has already persisted and sent the user's turn to the model context. Keep the visible user bubble for accepted streams and reconcile it with the persisted transcript instead of rolling it back like a pre-delivery HTTP validation failure.
|
||||
|
||||
@@ -54,6 +54,23 @@ describe("readChatAttachmentContents", () => {
|
||||
expect(formatChatAttachmentContents(result.attachmentContents)).toContain("hello from attachment");
|
||||
});
|
||||
|
||||
it("inlines TOML while retaining videos as non-inlineable attachments", async () => {
|
||||
const root = await makeRoot();
|
||||
await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true });
|
||||
await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "config.toml"), "enabled = true");
|
||||
await writeFile(join(root, ".fusion", "chat-attachments", "session-1", "demo.mp4"), Buffer.from("video"));
|
||||
|
||||
const result = await readChatAttachmentContents(root, { kind: "session", sessionId: "session-1" }, [
|
||||
attachment({ filename: "config.toml", originalName: "config.toml", mimeType: "text/x-toml" }),
|
||||
attachment({ filename: "demo.mp4", originalName: "demo.mp4", mimeType: "video/mp4" }),
|
||||
]);
|
||||
|
||||
expect(result.attachmentContents).toEqual([
|
||||
{ originalName: "config.toml", mimeType: "text/x-toml", text: "enabled = true" },
|
||||
]);
|
||||
expect(formatChatAttachmentContents(result.attachmentContents)).toContain("```toml");
|
||||
});
|
||||
|
||||
it("converts matching image attachments to base64 content blocks", async () => {
|
||||
const root = await makeRoot();
|
||||
await mkdir(join(root, ".fusion", "chat-attachments", "session-1"), { recursive: true });
|
||||
|
||||
@@ -46,7 +46,9 @@ const IMAGE_MIME_TYPES = new Set([
|
||||
]);
|
||||
|
||||
const TEXT_MIME_TYPES = new Set(
|
||||
[...CHAT_ALLOWED_MIME_TYPES].filter((mimeType) => !IMAGE_MIME_TYPES.has(mimeType)),
|
||||
[...CHAT_ALLOWED_MIME_TYPES].filter(
|
||||
(mimeType) => !IMAGE_MIME_TYPES.has(mimeType) && !mimeType.startsWith("video/"),
|
||||
),
|
||||
);
|
||||
|
||||
export const CHAT_TEXT_INLINE_LIMIT = 50 * 1024;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
/*
|
||||
FNXC:ChatAttachments 2026-08-03-00:00:
|
||||
Chat upload validation must match task attachments: the composer now allows the full task-store MIME
|
||||
set, so direct and room routes must persist videos, Markdown, and TOML instead of rejecting them.
|
||||
*/
|
||||
export const CHAT_ALLOWED_MIME_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/json",
|
||||
"text/yaml",
|
||||
"text/x-toml",
|
||||
@@ -12,3 +21,10 @@ export const CHAT_ALLOWED_MIME_TYPES = new Set([
|
||||
]);
|
||||
|
||||
export const CHAT_MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
|
||||
export const CHAT_MAX_VIDEO_ATTACHMENT_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
export function getChatAttachmentMaxSize(mimeType: string): number {
|
||||
return mimeType.startsWith("video/")
|
||||
? CHAT_MAX_VIDEO_ATTACHMENT_SIZE
|
||||
: CHAT_MAX_ATTACHMENT_SIZE;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RoomReplyGenerationError } from "../chat.js";
|
||||
import { createProjectScopedChatManager, resolveProjectChatContext } from "../chat-project-services.js";
|
||||
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
|
||||
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
||||
import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js";
|
||||
import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_VIDEO_ATTACHMENT_SIZE, getChatAttachmentMaxSize } from "./chat-attachment-config.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
function isSlugCollisionError(err: unknown): boolean {
|
||||
@@ -53,7 +53,7 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout
|
||||
}
|
||||
const multerError = err as { code?: string };
|
||||
if (multerError?.code === "LIMIT_FILE_SIZE") {
|
||||
next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`));
|
||||
next(badRequest(`File too large. Maximum: ${CHAT_MAX_VIDEO_ATTACHMENT_SIZE} bytes (100MB)`));
|
||||
return;
|
||||
}
|
||||
next(err as Error);
|
||||
@@ -415,8 +415,9 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout
|
||||
const file = req.file;
|
||||
if (!file) throw badRequest("file is required");
|
||||
if (!CHAT_ALLOWED_MIME_TYPES.has(file.mimetype)) throw badRequest(`Invalid mime type '${file.mimetype}'`);
|
||||
if (file.size > CHAT_MAX_ATTACHMENT_SIZE) {
|
||||
throw badRequest(`File too large (${file.size} bytes). Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`);
|
||||
const maxSize = getChatAttachmentMaxSize(file.mimetype);
|
||||
if (file.size > maxSize) {
|
||||
throw badRequest(`File too large (${file.size} bytes). Maximum: ${maxSize} bytes (${file.mimetype.startsWith("video/") ? "100MB" : "5MB"})`);
|
||||
}
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roomDir = resolve(scopedStore.getRootDir(), ".fusion", "chat-room-attachments", roomId);
|
||||
|
||||
@@ -12,7 +12,7 @@ the store-alignment fix and failed lint as unused; keep manager construction on
|
||||
store/chatStore pair only.
|
||||
*/
|
||||
import { getOrCreateScopedChatManager, resolveProjectChatContext } from "../chat-project-services.js";
|
||||
import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js";
|
||||
import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_VIDEO_ATTACHMENT_SIZE, getChatAttachmentMaxSize } from "./chat-attachment-config.js";
|
||||
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
||||
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
|
||||
import { TASK_PLANNER_CHAT_AGENT_ID_PREFIX } from "../chat.js";
|
||||
@@ -49,7 +49,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
}
|
||||
const multerError = err as { code?: string; message?: string };
|
||||
if (multerError?.code === "LIMIT_FILE_SIZE") {
|
||||
next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`));
|
||||
next(badRequest(`File too large. Maximum: ${CHAT_MAX_VIDEO_ATTACHMENT_SIZE} bytes (100MB)`));
|
||||
return;
|
||||
}
|
||||
next(err as Error);
|
||||
@@ -64,7 +64,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
}
|
||||
const multerError = err as { code?: string; message?: string };
|
||||
if (multerError?.code === "LIMIT_FILE_SIZE") {
|
||||
next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`));
|
||||
next(badRequest(`File too large. Maximum: ${CHAT_MAX_VIDEO_ATTACHMENT_SIZE} bytes (100MB)`));
|
||||
return;
|
||||
}
|
||||
next(err as Error);
|
||||
@@ -80,8 +80,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
throw badRequest(`Invalid mime type '${file.mimetype}'`);
|
||||
}
|
||||
|
||||
if (file.size > CHAT_MAX_ATTACHMENT_SIZE) {
|
||||
throw badRequest(`File too large (${file.size} bytes). Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`);
|
||||
const maxSize = getChatAttachmentMaxSize(file.mimetype);
|
||||
if (file.size > maxSize) {
|
||||
throw badRequest(`File too large (${file.size} bytes). Maximum: ${maxSize} bytes (${file.mimetype.startsWith("video/") ? "100MB" : "5MB"})`);
|
||||
}
|
||||
|
||||
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
|
||||
|
||||
Reference in New Issue
Block a user