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:
gsxdsm
2026-07-10 23:08:14 -07:00
parent a8eafbbb14
commit 324a794d62
10 changed files with 153 additions and 133 deletions

View File

@@ -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.

View File

@@ -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.

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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" && (

View File

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

View File

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