feat: draggable/resizable artifact viewer windows, Artifacts-first landing tab, uniform mobile tabs
- artifact viewers (image/video lightbox, PDF viewer, doc viewer) now host in the shared FloatingWindow: draggable by the viewer header, resizable by edge/corner handles, geometry persisted per viewer kind, closed by button or Escape; FloatingWindow gains an ariaLabel prop so headerless windows keep an accessible dialog name - the Artifacts view leads with the Artifacts tab and always lands on it; Project Files and Task Documents are secondary tabs (the old auto-select effect is gone) - mobile tab buttons rendered at mismatched heights (two-line "Project Files"/"Task Documents" grew past 44px while one-line "Artifacts" stayed at 44px); tabs now pin to the uniform 44px control height with non-wrapping labels in a scrollable row Verified live: window dragged (264,146 -> 144,164) and resized (1024x720 -> 872x618) in a real browser; mobile 390x844 lands on Artifacts with uniform tab buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,4 +4,4 @@
|
||||
|
||||
summary: Agents save screenshots, videos, HTML mockups, and PDFs as artifacts, shown in a new category gallery with doc editing.
|
||||
category: feature
|
||||
dev: fn_artifact_register gains a `path` payload source (file copied into managed storage, MIME inference, image/video/PDF signature validation) and is now always exposed to executor sessions (previously missing in ephemeral mode) with worktree-relative path resolution and executing-task default taskId; executor/planning prompts instruct agents to register visual/media deliverables (images, videos, HTML mockups, PDFs); the media route serves HTTP byte ranges for video/audio seeking; video attachments (100MB cap) bridge into the registry like images; HTML docs render as live sandboxed previews; new `GET`/`PATCH /api/artifacts/:id` routes plus `TaskStore.updateArtifact` and the `artifact:updated` SSE event power in-place doc editing in the new ArtifactsGallery (Images/Docs/PDFs/Videos/Audio/Other sections with per-category viewers, mobile-responsive).
|
||||
dev: fn_artifact_register gains a `path` payload source (file copied into managed storage, MIME inference, image/video/PDF signature validation) and is now always exposed to executor sessions (previously missing in ephemeral mode) with worktree-relative path resolution and executing-task default taskId; executor/planning prompts instruct agents to register visual/media deliverables (images, videos, HTML mockups, PDFs); the media route serves HTTP byte ranges for video/audio seeking; video attachments (100MB cap) bridge into the registry like images; HTML docs render as live sandboxed previews; new `GET`/`PATCH /api/artifacts/:id` routes plus `TaskStore.updateArtifact` and the `artifact:updated` SSE event power in-place doc editing in the new ArtifactsGallery (Images/Docs/PDFs/Videos/Audio/Other sections with per-category viewers, mobile-responsive); viewers open in draggable/resizable FloatingWindows, Artifacts is the first/landing tab of the view, and mobile tab buttons render at the uniform 44px control height.
|
||||
|
||||
@@ -809,7 +809,7 @@ For per-run aggregation, `GET /api/agents/:id/runs/:runId/cited-goals` returns `
|
||||
|
||||
## Artifacts View
|
||||
|
||||
Artifacts view aggregates project markdown files, task documents, and registered artifacts. The dashboard title is **Artifacts**; the internal tab bar keeps the shipped **Project Files**, **Task Documents**, and **Artifacts** labels.
|
||||
Artifacts view aggregates registered artifacts, project markdown files, and task documents. The dashboard title is **Artifacts**; the internal tab bar leads with **Artifacts** (the landing tab), followed by **Project Files** and **Task Documents**. On mobile the tab buttons render at the uniform 44px control height with non-wrapping labels in a horizontally scrollable row.
|
||||
|
||||
Features:
|
||||
|
||||
@@ -824,10 +824,10 @@ Features:
|
||||
- Video artifacts (agent-registered recordings, `path`-ingested MP4/WebM/MOV, and bridged video attachments) play with working seek because the media route serves HTTP byte ranges
|
||||
- HTML doc artifacts (`mimeType: text/html`) render as **live sandboxed previews** by default in the document viewer (scripts allowed, same-origin denied), with a Preview/Source toggle and the same Edit mode as other docs
|
||||
- **Edit any inline-content doc in place**: the document viewer's **Edit** button switches to an editor whose **Save** persists through `PATCH /api/artifacts/:id` and live-refreshes open galleries via the `artifact:updated` registry event; binary-backed documents stay read-only with a media link
|
||||
- Dismiss any viewer with the close button, backdrop click, or Escape
|
||||
- Every viewer (image/video lightbox, PDF viewer, document viewer) opens in a draggable, resizable floating window (drag by the viewer header, resize by any edge/corner; geometry persists per viewer kind); dismiss with the close button or Escape. Windows are non-blocking, so the gallery behind them stays interactive
|
||||
- Read artifact metadata on cards, rows, and viewer footers: title, optional description, author ID, timestamp, size, and linked task ID when present
|
||||
- Use the task link on a card/row or viewer footer to jump back to the originating task when the artifact has a `taskId`; inside task detail, the **Artifacts** tab shows that task's documents and registered media artifacts together
|
||||
- The gallery scales down at the mobile breakpoint (including landscape phones): category chips scroll horizontally, visual grids collapse to two columns, cards and rows go single-column, and viewers become full-screen sheets
|
||||
- The gallery scales down at the mobile breakpoint (including landscape phones): category chips scroll horizontally, visual grids collapse to two columns, cards and rows go single-column, and viewer windows clamp to the viewport
|
||||
- Loading state: the Artifacts tab shows `Loading artifacts…` while the first artifact list request is pending and no artifact results are loaded
|
||||
- Empty states: with no search query it shows `No artifacts yet.` plus the hint that artifacts are created by agents, users, and system tools; with a search query it shows `No artifacts match "<query>".`
|
||||
- Error state: a failed artifact list request uses the shared `Failed to load artifacts: <error>` panel with a **Retry** action that re-runs the artifact fetch
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -351,34 +351,33 @@ grids drop to two columns, cards/rows go single-column, and viewers become full-
|
||||
|
||||
/* ── Viewers (lightbox / pdf / doc) ────────────────────────────────── */
|
||||
|
||||
.artifacts-gallery-overlay {
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-11-11:30:
|
||||
Viewers live inside the shared FloatingWindow (draggable by the viewer header, resizable by edge/corner handles), so the viewer fills the window body and its media/doc content flexes with the user-chosen size instead of fixed dvh heights.
|
||||
*/
|
||||
.artifacts-gallery-window .floating-window__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-lg);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
width: min(46rem, 100%);
|
||||
max-height: 90dvh;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-md);
|
||||
border: thin solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer--wide {
|
||||
width: min(64rem, 100%);
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer-title {
|
||||
@@ -402,6 +401,7 @@ grids drop to two columns, cards/rows go single-column, and viewers become full-
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -411,13 +411,14 @@ grids drop to two columns, cards/rows go single-column, and viewers become full-
|
||||
.artifacts-gallery-viewer-media {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 65dvh;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer-pdf {
|
||||
width: 100%;
|
||||
height: 65dvh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: thin solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
@@ -426,7 +427,8 @@ grids drop to two columns, cards/rows go single-column, and viewers become full-
|
||||
/* HTML mockup live preview; white canvas because agent mockups usually assume a page background. */
|
||||
.artifacts-gallery-viewer-html {
|
||||
width: 100%;
|
||||
height: 60dvh;
|
||||
height: 100%;
|
||||
min-height: 20rem;
|
||||
border: thin solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff;
|
||||
@@ -530,24 +532,8 @@ grids drop to two columns, cards/rows go single-column, and viewers become full-
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.artifacts-gallery-overlay {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer,
|
||||
.artifacts-gallery-viewer--wide {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer-media {
|
||||
max-height: 70dvh;
|
||||
}
|
||||
|
||||
.artifacts-gallery-viewer-pdf {
|
||||
height: 70dvh;
|
||||
/* FloatingWindow clamps viewer geometry to the viewport, so mobile only tightens the inner padding. */
|
||||
.artifacts-gallery-viewer {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./ArtifactsGallery.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import type { Artifact, ArtifactWithTask } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { artifactMediaUrl, fetchArtifact, updateArtifact } from "../api";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-10-15:40:
|
||||
@@ -425,8 +426,6 @@ interface OverlayProps {
|
||||
|
||||
function useOverlayDismiss(onClose: () => void, closeRef: React.RefObject<HTMLButtonElement | null>) {
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
@@ -438,37 +437,40 @@ function useOverlayDismiss(onClose: () => void, closeRef: React.RefObject<HTMLBu
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [closeRef, onClose]);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-10-15:40:
|
||||
Each viewer owns its close-button ref and passes it to both OverlayShell (Escape/scroll-lock lifecycle + autofocus so keyboard users land on a dismiss affordance) and ViewerHeader (which renders the actual button).
|
||||
FNXC:ArtifactsGallery 2026-07-11-11:30:
|
||||
Artifact viewers host inside the app's canonical FloatingWindow so every popup is DRAGGABLE (by the
|
||||
viewer header — FloatingWindow ignores pointerdowns on buttons, so header actions stay clickable) and
|
||||
RESIZABLE (edge/corner handles), with geometry persisted per viewer kind. hideHeader keeps the
|
||||
viewer's own header chrome (title + actions + close); Escape still dismisses and the close button
|
||||
autofocuses via useOverlayDismiss. Each viewer owns its close-button ref and passes it to both
|
||||
OverlayShell and ViewerHeader.
|
||||
*/
|
||||
function OverlayShell({ label, onClose, children, wide, closeRef }: { label: string; onClose: () => void; children: React.ReactNode; wide?: boolean; closeRef: React.RefObject<HTMLButtonElement | null> }) {
|
||||
function OverlayShell({ label, onClose, children, wide, closeRef, windowKey, persistKey }: { label: string; onClose: () => void; children: React.ReactNode; wide?: boolean; closeRef: React.RefObject<HTMLButtonElement | null>; windowKey: string; persistKey: string }) {
|
||||
useOverlayDismiss(onClose, closeRef);
|
||||
|
||||
const handleOverlayClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open artifacts-gallery-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onClick={handleOverlayClick}
|
||||
<FloatingWindow
|
||||
windowKey={windowKey}
|
||||
title={null}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".artifacts-gallery-viewer-header"
|
||||
className="artifacts-gallery-window"
|
||||
ariaLabel={label}
|
||||
persistGeometryKey={persistKey}
|
||||
defaultSize={wide ? { width: 1024, height: 720 } : { width: 720, height: 640 }}
|
||||
minSize={{ width: 320, height: 280 }}
|
||||
>
|
||||
<div className={`artifacts-gallery-viewer${wide ? " artifacts-gallery-viewer--wide" : ""}`} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="artifacts-gallery-viewer">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -511,7 +513,7 @@ function MediaLightbox({ artifact, projectId, t, onClose, onOpenTask }: OverlayP
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<OverlayShell label={t("documents.lightboxLabel", "Artifact media preview")} onClose={onClose} wide closeRef={closeRef}>
|
||||
<OverlayShell label={t("documents.lightboxLabel", "Artifact media preview")} onClose={onClose} wide closeRef={closeRef} windowKey={`artifact-media-${artifact.id}`} persistKey="fn-artifact-viewer-media-geometry">
|
||||
<ViewerHeader
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
@@ -547,7 +549,7 @@ function PdfViewer({ artifact, projectId, t, onClose, onOpenTask }: OverlayProps
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<OverlayShell label={t("documents.pdfViewerLabel", "PDF artifact viewer")} onClose={onClose} wide closeRef={closeRef}>
|
||||
<OverlayShell label={t("documents.pdfViewerLabel", "PDF artifact viewer")} onClose={onClose} wide closeRef={closeRef} windowKey={`artifact-pdf-${artifact.id}`} persistKey="fn-artifact-viewer-pdf-geometry">
|
||||
<ViewerHeader
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
@@ -640,7 +642,7 @@ function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onAr
|
||||
};
|
||||
|
||||
return (
|
||||
<OverlayShell label={t("documents.docViewerLabel", "Document artifact viewer")} onClose={onClose} wide closeRef={closeRef}>
|
||||
<OverlayShell label={t("documents.docViewerLabel", "Document artifact viewer")} onClose={onClose} wide closeRef={closeRef} windowKey={`artifact-doc-${artifact.id}`} persistKey="fn-artifact-viewer-doc-geometry">
|
||||
<ViewerHeader
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -865,14 +865,26 @@ The artifacts tab is a thumbnail-first responsive media gallery for agent-create
|
||||
padding: 0 var(--space-md) var(--space-md);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsView 2026-07-11-11:30:
|
||||
On mobile the two-line-wrapping "Project Files"/"Task Documents" tabs grew past 44px while the
|
||||
one-line Artifacts tab stayed at 44px, so the buttons rendered at mismatched sizes. Stretch the
|
||||
row, forbid label wrapping, and pin every tab to the standard 44px mobile control height; the row
|
||||
scrolls horizontally when the labels do not fit.
|
||||
*/
|
||||
.documents-tab-bar {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.documents-tab {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.documents-hidden-toggle {
|
||||
|
||||
@@ -201,7 +201,8 @@ function TaskGroup({ taskId, taskTitle, documents, taskColumn, onOpenTask, rende
|
||||
|
||||
export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
|
||||
// FNXC:ArtifactsView 2026-07-11-11:30: Artifacts is the first tab and the landing tab — the view is the artifact gallery first, with project files and task documents as secondary tabs.
|
||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("artifacts");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
|
||||
const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false);
|
||||
@@ -210,7 +211,6 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const initialTabSetRef = useRef(false);
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const plainPreviewRef = useRef<HTMLPreElement>(null);
|
||||
// Markdown render toggle for project file preview
|
||||
@@ -267,8 +267,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
initialTabSetRef.current = false;
|
||||
setActiveTab("project");
|
||||
setActiveTab("artifacts");
|
||||
setSelectedFile(null);
|
||||
setShowHiddenProjectFiles(false);
|
||||
setFileContent(null);
|
||||
@@ -278,22 +277,6 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setTaskDocMarkdownStates(new Map());
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTabSetRef.current || documentsLoading || projectFilesLoading || artifactsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectFiles.length > 0) {
|
||||
setActiveTab("project");
|
||||
} else if (documents.length > 0) {
|
||||
setActiveTab("tasks");
|
||||
} else if (artifacts.length > 0) {
|
||||
setActiveTab("artifacts");
|
||||
}
|
||||
|
||||
initialTabSetRef.current = true;
|
||||
}, [artifacts.length, artifactsLoading, documents.length, documentsLoading, projectFiles.length, projectFilesLoading]);
|
||||
|
||||
const groupedDocuments = useMemo(() => {
|
||||
const groups = new Map<string, TaskDocumentWithTask[]>();
|
||||
for (const doc of documents) {
|
||||
@@ -481,6 +464,20 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
|
||||
<div className="documents-controls-row">
|
||||
<div className="documents-tab-bar" role="tablist" aria-label={t("documents.sectionsLabel", "Documents sections")}>
|
||||
{/*
|
||||
FNXC:ArtifactRegistry 2026-07-11-11:30:
|
||||
Artifacts leads the tab bar (and is the landing tab) — the view is the artifact gallery first; project files and task documents are secondary tabs.
|
||||
*/}
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "artifacts" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "artifacts"}
|
||||
aria-label={t("documents.showArtifacts", "Show artifacts")}
|
||||
onClick={() => handleTabChange("artifacts")}
|
||||
>
|
||||
{t("documents.artifactsTab", "Artifacts")}
|
||||
<span className="documents-tab-count">{artifacts.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "project" ? " active" : ""}`}
|
||||
role="tab"
|
||||
@@ -501,20 +498,6 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
{t("documents.taskDocumentsTab", "Task Documents")}
|
||||
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
||||
</button>
|
||||
{/*
|
||||
FNXC:ArtifactRegistry 2026-06-21-04:46:
|
||||
The Documents navigation has one canonical Artifacts tab so media produced by any agent is discoverable without adding another dashboard destination.
|
||||
*/}
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "artifacts" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "artifacts"}
|
||||
aria-label={t("documents.showArtifacts", "Show artifacts")}
|
||||
onClick={() => handleTabChange("artifacts")}
|
||||
>
|
||||
{t("documents.artifactsTab", "Artifacts")}
|
||||
<span className="documents-tab-count">{artifacts.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "project" && (
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface FloatingWindowProps {
|
||||
closeOnOutsidePointerDown?: boolean;
|
||||
/** Layer band for z-index claiming. Task details stay with the board/task-detail surface; utilities use the global floating stack. */
|
||||
layer?: "utility" | "task-detail";
|
||||
// FNXC:FloatingWindow 2026-07-11-11:30: accessible name for the dialog overlay so headerless windows (e.g. artifact viewers with their own header chrome) stay queryable/announcable by label.
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 720;
|
||||
@@ -158,6 +160,7 @@ export function FloatingWindow({
|
||||
persistGeometryKey,
|
||||
closeOnOutsidePointerDown = false,
|
||||
layer = "utility",
|
||||
ariaLabel,
|
||||
}: FloatingWindowProps) {
|
||||
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
|
||||
const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null);
|
||||
@@ -411,6 +414,7 @@ export function FloatingWindow({
|
||||
className="floating-window-overlay"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={`floating-window-overlay-${windowKey}`}
|
||||
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
|
||||
style={{ zIndex }}
|
||||
|
||||
@@ -300,10 +300,19 @@ describe("DocumentsView", () => {
|
||||
window.innerWidth = originalInnerWidth;
|
||||
});
|
||||
|
||||
it("renders project files tab with markdown file list", () => {
|
||||
/*
|
||||
FNXC:ArtifactsView 2026-07-11-11:30:
|
||||
The view lands on the Artifacts tab (Artifacts-first ordering); Project Files is an explicit click away.
|
||||
*/
|
||||
it("lands on the artifacts tab and shows project files after switching tabs", () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Artifacts" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /show artifacts/i })).toHaveAttribute("aria-selected", "true");
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[0]).toHaveAccessibleName(/show artifacts/i);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
expect(screen.getByRole("tab", { name: /show project markdown files/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("button", { name: "Open README.md" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open docs/guide.md" })).toBeInTheDocument();
|
||||
@@ -322,6 +331,8 @@ describe("DocumentsView", () => {
|
||||
}));
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Open .hidden/notes.md" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /show hidden project files/i })).toHaveAttribute("aria-pressed", "false");
|
||||
@@ -344,9 +355,8 @@ describe("DocumentsView", () => {
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
expect(screen.getByText("KB-001")).toBeInTheDocument();
|
||||
expect(screen.getByText("KB-002")).toBeInTheDocument();
|
||||
@@ -379,9 +389,8 @@ describe("DocumentsView", () => {
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const doneGroup = screen.getByRole("button", { name: /expand documents for task KB-DONE/i }).closest(".documents-group");
|
||||
const todoGroup = screen.getByRole("button", { name: /expand documents for task KB-TODO/i }).closest(".documents-group");
|
||||
@@ -423,9 +432,8 @@ describe("DocumentsView", () => {
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const doneGroup = screen.getByRole("button", { name: /expand documents for task KB-DONE/i }).closest(".documents-group") as HTMLElement;
|
||||
const status = within(doneGroup).getByLabelText("Task status: Done");
|
||||
@@ -497,7 +505,7 @@ describe("DocumentsView", () => {
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens and dismisses the image and video artifact lightbox by click keyboard close backdrop and escape", () => {
|
||||
it("opens image and video viewers in a draggable resizable floating window dismissed by close button and escape", () => {
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: mockArtifacts,
|
||||
loading: false,
|
||||
@@ -505,14 +513,20 @@ describe("DocumentsView", () => {
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const { container } = render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-11-11:30:
|
||||
Viewers host in the shared FloatingWindow: draggable (drag/resize handle testids) and resizable,
|
||||
closed by the close button or Escape. The floating overlay is intentionally click-through
|
||||
(non-blocking windows), so backdrop-click dismissal and body scroll locking are gone by design.
|
||||
The FloatingWindow portals to document.body, so media queries use document, not the container.
|
||||
*/
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" }));
|
||||
let dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
expect(within(dialog).getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
expect(screen.getByTestId("floating-window-artifact-media-artifact-image")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Close artifact preview" }));
|
||||
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
||||
@@ -520,16 +534,15 @@ describe("DocumentsView", () => {
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Expand Image artifact" }), { key: "Enter" });
|
||||
dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
expect(within(dialog).getByRole("img", { name: "Image artifact" })).toBeInTheDocument();
|
||||
fireEvent.click(dialog);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Expand Video artifact" }), { key: " " });
|
||||
dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
expect(within(dialog).getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
|
||||
expect(container.querySelector(".artifacts-gallery-viewer-media-frame video")).toHaveAttribute("controls");
|
||||
expect(document.querySelector(".artifacts-gallery-viewer-media-frame video")).toHaveAttribute("controls");
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
||||
expect(document.body.style.overflow).toBe("");
|
||||
});
|
||||
|
||||
it("renders artifacts empty loading error retry and mobile gallery states", async () => {
|
||||
@@ -641,15 +654,16 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
mockFetchArtifact.mockResolvedValue(htmlArtifact);
|
||||
|
||||
const { container } = render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
|
||||
const htmlCard = screen.getByRole("article", { name: "Artifact Login mockup" });
|
||||
fireEvent.click(within(htmlCard).getByRole("button", { name: "Open Login mockup" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Document artifact viewer" });
|
||||
|
||||
// The FloatingWindow portals to document.body, so query the document rather than the render container.
|
||||
await waitFor(() => {
|
||||
const iframe = container.querySelector(".artifacts-gallery-viewer-html");
|
||||
const iframe = document.querySelector(".artifacts-gallery-viewer-html");
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe).toHaveAttribute("sandbox", "allow-scripts");
|
||||
expect(iframe).toHaveAttribute("srcdoc", "<h1>Login mock</h1>");
|
||||
@@ -657,12 +671,14 @@ describe("DocumentsView", () => {
|
||||
|
||||
// The toggle shows the CURRENT mode (matching the Markdown/Plain convention): "Preview" while previewing.
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Preview" }));
|
||||
expect(container.querySelector(".artifacts-gallery-viewer-html")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".artifacts-gallery-viewer-html")).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByText("<h1>Login mock</h1>")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking project file shows content", async () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
|
||||
@@ -681,6 +697,8 @@ describe("DocumentsView", () => {
|
||||
*/
|
||||
it("shows a Read-only badge in the project file preview header in plain and markdown modes", async () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
await screen.findByText(/Hello docs/);
|
||||
@@ -695,6 +713,8 @@ describe("DocumentsView", () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
const plainPreview = await screen.findByText(/Hello docs/);
|
||||
@@ -713,6 +733,8 @@ describe("DocumentsView", () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
await screen.findByText(/Hello docs/);
|
||||
@@ -756,9 +778,8 @@ describe("DocumentsView", () => {
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /search task documents/i }), {
|
||||
target: { value: "alpha" },
|
||||
@@ -789,6 +810,8 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
expect(screen.getByText(/failed to load project files/i)).toBeInTheDocument();
|
||||
|
||||
@@ -816,6 +839,8 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
expect(screen.getByText("Loading project markdown files…")).toBeInTheDocument();
|
||||
|
||||
@@ -828,6 +853,8 @@ describe("DocumentsView", () => {
|
||||
window.innerWidth = 600;
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
|
||||
@@ -849,9 +876,8 @@ describe("DocumentsView", () => {
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /open task KB-001/i }));
|
||||
|
||||
@@ -865,6 +891,8 @@ describe("DocumentsView", () => {
|
||||
mockFetchWorkspaceFileContent.mockRejectedValue(new Error("cannot read file"));
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
|
||||
@@ -880,6 +908,8 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
|
||||
@@ -900,6 +930,8 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
|
||||
@@ -930,6 +962,8 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
|
||||
// Toggle project file to markdown mode
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
@@ -976,9 +1010,8 @@ describe("DocumentsView", () => {
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// Expand task group
|
||||
fireEvent.click(screen.getByRole("button", { name: /expand documents for task KB-001/i }));
|
||||
|
||||
Reference in New Issue
Block a user