diff --git a/.changeset/fn-8037-image-preview-modal.md b/.changeset/fn-8037-image-preview-modal.md new file mode 100644 index 0000000000..aff456a306 --- /dev/null +++ b/.changeset/fn-8037-image-preview-modal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Quick Add image attachments now show compact previews you can tap to open full-size in a resizable window. +category: feature +dev: QuickEntryBox/TaskForm/InlineCreateCard pending-image previews shrink via InlineCreateCard.css and open in the shared FloatingWindow (dedicated floating-window--image-preview class, full-screen on mobile); remove button stays a separate click target. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c13f95e8ab..abc5b27dca 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -500,10 +500,10 @@ 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. - + -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 pending previews before task creation and upload the images to the created task afterward. 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 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 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. diff --git a/packages/dashboard/app/__tests__/quick-entry-image-preview-modal.test.tsx b/packages/dashboard/app/__tests__/quick-entry-image-preview-modal.test.tsx new file mode 100644 index 0000000000..c474bdcf7d --- /dev/null +++ b/packages/dashboard/app/__tests__/quick-entry-image-preview-modal.test.tsx @@ -0,0 +1,60 @@ +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"; + +const image = { + file: new File(["image"], "preview.png", { type: "image/png" }), + previewUrl: "blob:preview", +}; + +describe("pending task image previews", () => { + it.each([ + "quick-entry-preview", + "task-form-preview", + "inline-create-preview", + ])("opens and dismisses the shared %s preview affordance", async (testIdPrefix) => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + render( + , + ); + + const openButton = screen.getByTestId(`${testIdPrefix}-open-0`); + expect(openButton).toHaveAccessibleName("Open image preview.png"); + await user.click(openButton); + + const window = screen.getByTestId("floating-window-pending-image-preview"); + expect(window).toHaveClass("floating-window--image-preview"); + expect(screen.getByRole("img", { name: "preview.png" })).toHaveAttribute("src", "blob:preview"); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => expect(screen.queryByTestId("floating-window-pending-image-preview")).toBeNull()); + expect(openButton).toHaveFocus(); + + await user.keyboard("{Enter}"); + expect(screen.getByTestId("floating-window-pending-image-preview")).toBeTruthy(); + fireEvent.click(screen.getByTestId(`${testIdPrefix}-remove-0`)); + expect(onRemove).toHaveBeenCalledWith(0); + expect(screen.queryByTestId("floating-window-pending-image-preview")).toBeNull(); + }); + + it("renders no preview shells without pending images", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".inline-create-previews")).toBeNull(); + expect(screen.queryByTestId("quick-entry-preview-open-0")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index 87cb2939de..8113497def 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -123,6 +123,28 @@ On mobile/narrow app viewports, opening Quick Chat should present the full Chat .floating-window--chat .floating-window__resize-handle { display: none; } + + /* + FNXC:QuickAddAttachments 2026-07-16-00:00: + Pending task-attachment images open in a movable, resizable FloatingWindow on desktop. On narrow touch viewports the dedicated image-preview variant must instead fill the screen and remove desktop resize handles. + */ + .floating-window--image-preview { + inset: 0 !important; + width: 100vw !important; + height: 100dvh !important; + min-width: 0 !important; + min-height: 0 !important; + max-width: 100vw !important; + max-height: 100dvh !important; + border: none; + border-radius: 0; + box-shadow: none; + } + + .floating-window--image-preview .floating-window__resize-handle { + display: none; + } + /* FNXC:GitHubImport 2026-07-15-16:00: Import details use FloatingWindow for desktop drag and resize; on mobile this scoped variant becomes a full-screen sheet diff --git a/packages/dashboard/app/components/InlineCreateCard.css b/packages/dashboard/app/components/InlineCreateCard.css index c739f8aa67..804bf9b43b 100644 --- a/packages/dashboard/app/components/InlineCreateCard.css +++ b/packages/dashboard/app/components/InlineCreateCard.css @@ -166,21 +166,25 @@ margin-left: auto; } +/* +FNXC:QuickAddAttachments 2026-07-16-00:00: +Pending-image previews share this compact tokenized footprint across QuickEntryBox, TaskForm, and InlineCreateCard so Board, List, and form composers keep their action areas usable. The thumbnail remains a crop; its shared renderer provides the full-size open action. +*/ .inline-create-previews { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-md); - max-height: 120px; + max-height: calc(var(--space-2xl) * 2.5); overflow-y: auto; - padding: 2px; + padding: var(--space-xs); } .inline-create-preview { position: relative; - width: 48px; - height: 48px; - border: 1px solid var(--border); + width: var(--space-2xl); + height: var(--space-2xl); + border: thin solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--card); diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 8137250994..af709ad7cd 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -17,6 +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"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const STORAGE_KEY = "kb-inline-create-text"; @@ -868,24 +869,13 @@ export function InlineCreateCard({ /> )} - {pendingImages.length > 0 && ( -
- {pendingImages.map((img, i) => ( -
- {img.file.name} - -
- ))} -
- )} + {isExpanded && (
diff --git a/packages/dashboard/app/components/PendingImagePreviews.css b/packages/dashboard/app/components/PendingImagePreviews.css new file mode 100644 index 0000000000..8a216c5572 --- /dev/null +++ b/packages/dashboard/app/components/PendingImagePreviews.css @@ -0,0 +1,35 @@ +/* +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; +} diff --git a/packages/dashboard/app/components/PendingImagePreviews.tsx b/packages/dashboard/app/components/PendingImagePreviews.tsx new file mode 100644 index 0000000000..91b423a255 --- /dev/null +++ b/packages/dashboard/app/components/PendingImagePreviews.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { FloatingWindow } from "./FloatingWindow"; +import "./PendingImagePreviews.css"; + +export interface PendingImagePreviewItem { + file: File; + previewUrl: string; +} + +interface PendingImagePreviewsProps { + images: PendingImagePreviewItem[]; + onRemove: (index: number) => void; + disabled?: boolean; + removeLabel: string; + testIdPrefix: string; +} + +/* +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. +*/ +export function PendingImagePreviews({ + images, + onRemove, + disabled = false, + removeLabel, + testIdPrefix, +}: PendingImagePreviewsProps) { + const [selectedPreviewUrl, setSelectedPreviewUrl] = useState(null); + const returnFocusRef = useRef(null); + const selectedImage = selectedPreviewUrl + ? images.find((image) => image.previewUrl === selectedPreviewUrl) ?? null + : null; + + const closePreview = useCallback((restoreFocus = true) => { + const buttonToFocus = returnFocusRef.current; + setSelectedPreviewUrl(null); + returnFocusRef.current = null; + if (restoreFocus) { + requestAnimationFrame(() => buttonToFocus?.focus()); + } + }, []); + + useEffect(() => { + if (!selectedImage) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closePreview(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [closePreview, selectedImage]); + + const openPreview = useCallback((previewUrl: string, button: HTMLButtonElement) => { + returnFocusRef.current = button; + setSelectedPreviewUrl(previewUrl); + }, []); + + const handleRemove = useCallback((index: number, previewUrl: string) => { + if (selectedPreviewUrl === previewUrl) { + closePreview(false); + } + onRemove(index); + }, [closePreview, onRemove, selectedPreviewUrl]); + + if (images.length === 0) return null; + + return ( + <> +
+ {images.map((image, index) => ( +
+ + +
+ ))} +
+ {selectedImage && ( + +
+ {selectedImage.file.name} +
+
+ )} + + ); +} diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 5edf0c6613..e7d3b07a2f 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -17,6 +17,7 @@ import { NodeHealthDot } from "./NodeHealthDot"; import { ProviderIcon } from "./ProviderIcon"; import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; import { WorkflowIcon } from "./WorkflowIcon"; +import { PendingImagePreviews } from "./PendingImagePreviews"; import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator"; const STORAGE_KEY = "kb-quick-entry-text"; @@ -2348,25 +2349,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
)} - {pendingImages.length > 0 && ( -
- {pendingImages.map((img, index) => ( -
- {img.file.name} - -
- ))} -
- )} + {isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
string): string { if (status === "online") return t("taskForm.nodeStatusOnline", "Online"); @@ -1200,24 +1201,13 @@ export function TaskForm({ {/* Attachments */}
- {pendingImages.length > 0 && ( -
- {pendingImages.map((img, i) => ( -
- {img.file.name} - -
- ))} -
- )} +