Files
fusion/packages/dashboard/app/components/DocumentsView.tsx
gsxdsm b493a1e9e5 FN-7434: show task status badges in document groups
Show task completion metadata directly in Documents task group headers.

- Render task status badges for task document groups when column metadata is available.
- Preserve non-interactive status metadata in collapsed and mobile task group headers.
- Cover done, non-done, archived, custom, and legacy task document status cases.
- Document the Documents view status badge behavior and add a patch changeset.

Files changed:
 .changeset/fn-7434-document-task-status.md         |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/DocumentsView.css     |  16 ++-
 .../dashboard/app/components/DocumentsView.tsx     |  31 ++++-
 .../components/__tests__/DocumentsView.test.tsx    | 148 +++++++++++++++++++++
 5 files changed, 199 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7434

Fusion-Task-Lineage: b04161b3-c052-4eee-b9ed-5760eb9b7a4e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-02 12:00:58 -07:00

889 lines
38 KiB
TypeScript

import "./DocumentsView.css";
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent, type KeyboardEvent, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { artifactMediaUrl, fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
import { useArtifacts } from "../hooks/useArtifacts";
import { useDocuments } from "../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
import { useSelectionComment } from "../hooks/useSelectionComment";
import { SelectionCommentPopover } from "./SelectionCommentPopover";
import { LoadingSpinner } from "./LoadingSpinner";
import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia";
import { ViewHeader } from "./ViewHeader";
import { useColumnLabel } from "../i18n/labels";
const MOBILE_BREAKPOINT = 768;
type DocumentsTab = "project" | "tasks" | "artifacts";
export interface DocumentsViewProps {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
onOpenDetail: (task: TaskDetail) => void;
onOpenArtifactTaskDetail?: (task: TaskDetail) => void;
onSendSelectionToTask?: (description: string) => void;
}
interface DocumentCardProps {
document: TaskDocumentWithTask;
renderMarkdown: boolean;
onToggleMarkdown: () => void;
}
interface TaskGroupProps {
taskId: string;
taskTitle?: string;
documents: TaskDocumentWithTask[];
taskColumn?: string;
onOpenTask: (taskId: string) => void;
renderMarkdownStates: Map<string, boolean>;
onToggleMarkdown: (docId: string) => void;
}
interface ArtifactCardProps {
artifact: ArtifactWithTask;
projectId?: string;
onOpenTask: (taskId: string) => void;
onExpandMedia: (artifact: ArtifactWithTask) => void;
}
function formatTimestamp(iso?: string): string {
if (!iso) return "";
return new Date(iso).toLocaleString();
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getContentPreview(content: string, maxLength: number = 200): string {
if (content.length <= maxLength) return content;
return `${content.substring(0, maxLength)}…`;
}
function getTaskColumnStatusDotClass(taskColumn: string): string {
if (taskColumn === "done") return "status-dot status-dot--online";
if (taskColumn === "archived") return "status-dot status-dot--offline";
if (taskColumn === "todo" || taskColumn === "triage") return "status-dot status-dot--pending";
return "status-dot status-dot--connecting";
}
function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) {
const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false);
const preview = getContentPreview(document.content);
const showExpand = document.content.length > 200;
return (
<div className="document-card">
<div className="document-card-header">
<div className="document-card-key">
<FileText size={14} />
<span className="document-card-key-text">{document.key}</span>
<span className="document-card-revision-badge">v{document.revision}</span>
</div>
<div className="document-card-actions">
<button
className="btn btn-sm document-card-expand-btn"
onClick={() => setExpanded((current) => !current)}
title={expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")}
aria-label={expanded ? t("documents.collapseContent", "Collapse content") : t("documents.expandContent", "Expand content")}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
</div>
<div className="document-card-meta">
<span className="document-card-author">{document.author}</span>
<span className="document-card-separator">·</span>
<span className="document-card-date">{formatTimestamp(document.updatedAt)}</span>
</div>
<div className={`document-card-content${expanded ? " document-card-content--expanded" : ""}`}>
{expanded ? (
<>
<div className="document-card-content-header">
<button
className="btn btn-sm document-mode-toggle"
onClick={onToggleMarkdown}
aria-label={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
aria-pressed={renderMarkdown}
title={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
>
{renderMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
</button>
</div>
{renderMarkdown ? (
<div className="document-card-content-markdown">
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown>
</div>
</div>
) : (
<pre className="document-card-content-text">{document.content}</pre>
)}
</>
) : (
<p className="document-card-preview">{preview}</p>
)}
{showExpand && !expanded && (
<p className="document-card-preview-truncated">…</p>
)}
</div>
</div>
);
}
function TaskGroup({ taskId, taskTitle, documents, taskColumn, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
const [expanded, setExpanded] = useState(false);
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
return (
<div className="documents-group">
<div className="documents-group-header">
<button
className="documents-group-toggle-btn"
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
aria-label={`${expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")} documents for task ${taskId}`}
>
<span className="documents-group-toggle" aria-hidden="true">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
<span className="documents-group-task-id">{taskId}</span>
<span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span>
</button>
{taskStatusLabel ? (
<span className="documents-group-status badge" aria-label={t("documents.taskStatusAria", "Task status: {{status}}", { status: taskStatusLabel })}>
<span className={taskStatusDotClass} aria-hidden="true" />
<span>{taskStatusLabel}</span>
</span>
) : null}
<span className="documents-group-count">{t("documents.docCount", "{{count}} doc{{plural}}", { count: documents.length, plural: documents.length !== 1 ? "s" : "" })}</span>
<button
className="documents-group-task-link"
onClick={() => onOpenTask(taskId)}
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId, title: taskTitle || t("documents.untitled", "Untitled") })}
>
{t("documents.openTask", "Open task")}
</button>
</div>
{expanded && (
<div className="documents-group-content">
{documents.map((doc) => (
<DocumentCard
key={doc.id}
document={doc}
renderMarkdown={renderMarkdownStates.get(doc.id) ?? false}
onToggleMarkdown={() => onToggleMarkdown(doc.id)}
/>
))}
</div>
)}
</div>
);
}
function ArtifactCard({ artifact, projectId, onOpenTask, onExpandMedia }: ArtifactCardProps) {
const { t } = useTranslation("app");
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
const typeLabel = getArtifactTypeLabel(t, artifact.type);
const preview = artifact.content ? getContentPreview(artifact.content, 320) : artifact.description;
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
const isExpandableMedia = artifact.type === "image" || artifact.type === "video";
const handleExpandKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onExpandMedia(artifact);
}
}, [artifact, onExpandMedia]);
return (
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
{isExpandableMedia ? (
<div
className="documents-artifact-preview documents-artifact-preview--expandable"
role="button"
tabIndex={0}
aria-label={t("documents.expandArtifact", "Expand {{title}}", { title })}
onClick={() => onExpandMedia(artifact)}
onKeyDown={handleExpandKeyDown}
>
{artifact.type === "image" ? (
<img className="documents-artifact-media" src={mediaUrl} alt={title} loading="lazy" />
) : (
<video className="documents-artifact-media" src={mediaUrl} muted preload="metadata" aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />
)}
<span className="documents-artifact-expand-hint">{t("documents.expandArtifactHint", "Click to expand")}</span>
</div>
) : (
<div className="documents-artifact-preview">
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
</div>
)}
<div className="documents-artifact-body">
<div className="documents-artifact-header">
<span className="documents-artifact-type-badge">{typeLabel}</span>
<span className="documents-artifact-author">{artifact.authorId}</span>
</div>
<h3 className="documents-artifact-title">{title}</h3>
{artifact.description && <p className="documents-artifact-description">{artifact.description}</p>}
<div className="documents-artifact-meta">
<span>{formatTimestamp(artifact.createdAt)}</span>
{artifact.sizeBytes !== undefined && <span>{formatFileSize(artifact.sizeBytes)}</span>}
</div>
{artifact.taskId && (
<button
className="documents-group-task-link documents-artifact-task-link"
onClick={() => onOpenTask(artifact.taskId as string)}
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId: artifact.taskId, title: artifact.taskTitle || t("documents.untitled", "Untitled") })}
>
{t("documents.openTask", "Open task")}
</button>
)}
</div>
</article>
);
}
export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
const { t } = useTranslation("app");
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
const [searchQuery, setSearchQuery] = useState("");
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false);
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
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
const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false);
// Markdown render toggles per task document card (scoped by doc ID)
const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState<Map<string, boolean>>(new Map());
/*
FNXC:ArtifactRegistry 2026-06-21-23:22:
Image and video artifacts open in a dismissible lightbox, but audio, document, and generic artifacts remain normal cards so non-previewable media never receive orphaned expand targets.
*/
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactWithTask | null>(null);
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
const [selectionCommentOpen, setSelectionCommentOpen] = useState(false);
const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen });
const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen });
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
const artifactSearchQuery = activeTab === "artifacts" ? searchQuery.trim() : "";
const {
documents,
loading: documentsLoading,
error: documentsError,
refresh: refreshDocuments,
} = useDocuments({
projectId,
searchQuery: taskSearchQuery || undefined,
includeProjectFiles: false,
});
const {
files: projectFiles,
loading: projectFilesLoading,
error: projectFilesError,
refresh: refreshProjectFiles,
} = useProjectMarkdownFiles(projectId, { showHidden: showHiddenProjectFiles });
const {
artifacts,
loading: artifactsLoading,
error: artifactsError,
refresh: refreshArtifacts,
} = useArtifacts({
projectId,
searchQuery: artifactSearchQuery || undefined,
});
useEffect(() => {
const updateMobile = () => {
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
};
updateMobile();
window.addEventListener("resize", updateMobile);
return () => {
window.removeEventListener("resize", updateMobile);
};
}, []);
useEffect(() => {
initialTabSetRef.current = false;
setActiveTab("project");
setSelectedFile(null);
setShowHiddenProjectFiles(false);
setFileContent(null);
setFileError(null);
setFileLoading(false);
setRenderProjectMarkdown(false);
setTaskDocMarkdownStates(new Map());
setLightboxArtifact(null);
}, [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) {
const existing = groups.get(doc.taskId) || [];
groups.set(doc.taskId, [...existing, doc]);
}
return Array.from(groups.entries())
.map(([taskId, docs]) => {
const sortedDocs = [...docs].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return {
taskId,
taskTitle: sortedDocs[0]?.taskTitle,
/*
FNXC:DocumentsView 2026-07-02-00:00:
Task document groups must surface the parent task completion state in the header so operators can identify done work without expanding documents or opening task details. Use the first available column from the grouped task documents because legacy rows may omit taskColumn.
*/
taskColumn: sortedDocs.find((doc) => doc.taskColumn)?.taskColumn,
documents: sortedDocs,
latestUpdated: sortedDocs[0]?.updatedAt ?? "",
};
})
.sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated));
}, [documents]);
const filteredProjectFiles = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase();
if (!normalizedQuery) {
return projectFiles;
}
return projectFiles.filter((file) => {
const normalizedPath = file.path.toLowerCase();
const normalizedName = file.name.toLowerCase();
return normalizedPath.includes(normalizedQuery) || normalizedName.includes(normalizedQuery);
});
}, [projectFiles, searchQuery]);
useEffect(() => {
if (!selectedFile) {
return;
}
const selectedStillExists = projectFiles.some((file) => file.path === selectedFile.path);
if (!selectedStillExists) {
setSelectedFile(null);
setFileContent(null);
setFileError(null);
setFileLoading(false);
}
}, [projectFiles, selectedFile]);
const handleSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
}, []);
const clearSearch = useCallback(() => {
setSearchQuery("");
}, []);
const handleTabChange = useCallback((tab: DocumentsTab) => {
setActiveTab(tab);
}, []);
const handleOpenTask = useCallback(async (taskId: string) => {
try {
const task = await fetchTaskDetail(taskId, projectId);
onOpenDetail(task);
} catch {
addToast(`Failed to open task ${taskId}`, "error");
}
}, [projectId, onOpenDetail, addToast]);
/*
FNXC:ArtifactRegistry 2026-06-22-12:00:
Artifact cards should open their parent task in the same movable task popup
used by board/list pop-out flows, not the fixed task-detail modal. Keep task
document groups on the existing onOpenDetail path so only artifact-origin
task opens change surface.
*/
const handleOpenArtifactTask = useCallback(async (taskId: string) => {
try {
const task = await fetchTaskDetail(taskId, projectId);
(onOpenArtifactTaskDetail ?? onOpenDetail)(task);
} catch {
addToast(`Failed to open task ${taskId}`, "error");
}
}, [projectId, onOpenArtifactTaskDetail, onOpenDetail, addToast]);
const handleSelectProjectFile = useCallback(async (file: MarkdownFileEntry) => {
setSelectedFile(file);
setFileLoading(true);
setFileError(null);
setFileContent(null);
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
try {
const fileResponse = await fetchWorkspaceFileContent("project", file.path, projectId);
if (requestIdRef.current !== requestId) {
return;
}
setFileContent(fileResponse.content);
} catch (err) {
if (requestIdRef.current !== requestId) {
return;
}
const message = err instanceof Error ? err.message : `Failed to open ${file.path}`;
setFileError(message);
addToast(message, "error");
} finally {
if (requestIdRef.current === requestId) {
setFileLoading(false);
}
}
}, [projectId, addToast]);
const handleBackToFileList = useCallback(() => {
setSelectedFile(null);
setFileContent(null);
setFileError(null);
setFileLoading(false);
}, []);
const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
setTaskDocMarkdownStates((prev) => {
const next = new Map(prev);
const current = next.get(docId) ?? false;
next.set(docId, !current);
return next;
});
}, []);
const handleExpandArtifact = useCallback((artifact: ArtifactWithTask) => {
lightboxReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setLightboxArtifact(artifact);
}, []);
const handleCloseLightbox = useCallback(() => {
setLightboxArtifact(null);
lightboxReturnFocusRef.current?.focus();
lightboxReturnFocusRef.current = null;
}, []);
useEffect(() => {
if (!lightboxArtifact) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
lightboxCloseRef.current?.focus();
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
handleCloseLightbox();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", handleKeyDown);
};
}, [handleCloseLightbox, lightboxArtifact]);
const handleLightboxOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
handleCloseLightbox();
}
}, [handleCloseLightbox]);
const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
const handleRetry = useCallback(async () => {
if (activeTab === "project") {
await refreshProjectFiles();
return;
}
if (activeTab === "tasks") {
await refreshDocuments();
return;
}
await refreshArtifacts();
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? documents.length : artifacts.length;
const selectionPopover = selectedFile && onSendSelectionToTask && activeProjectSelection ? (
<SelectionCommentPopover
selectedText={activeProjectSelection.selectedText}
anchorRect={activeProjectSelection.anchorRect}
filePath={selectedFile.path}
onSubmit={onSendSelectionToTask}
onOpenChange={setSelectionCommentOpen}
/>
) : null;
const searchPlaceholder = activeTab === "project"
? t("documents.searchProjectFiles", "Search project markdown files…")
: activeTab === "tasks"
? t("documents.searchTaskDocuments", "Search task documents…")
: t("documents.searchArtifacts", "Search artifacts…");
return (
<div className="documents-view">
{/*
FNXC:Navigation 2026-06-22-01:10:
Documents/Artifacts adopts the shared ViewHeader (CC-modeled) for a consistent main-content title row; the result count rides in the header actions while the tab bar, hidden-files toggle, and search stay in the controls row below.
FNXC:Navigation 2026-06-21-18:25: FN-6890 keeps the top-level title as Artifacts (renamed from Documents) without changing internal task-document tabs or artifact sub-tabs.
*/}
<div className="documents-view-header">
<ViewHeader
icon={FileText}
title={t("documents.title", "Artifacts")}
actions={(
<span className="documents-view-count">
{t("documents.resultCount", "{{count}} result{{plural}}", { count: activeCount, plural: activeCount !== 1 ? "s" : "" })}
</span>
)}
/>
<div className="documents-controls-row">
<div className="documents-tab-bar" role="tablist" aria-label={t("documents.sectionsLabel", "Documents sections")}>
<button
className={`btn documents-tab${activeTab === "project" ? " active" : ""}`}
role="tab"
aria-selected={activeTab === "project"}
aria-label={t("documents.showProjectFiles", "Show project markdown files")}
onClick={() => handleTabChange("project")}
>
{t("documents.projectFilesTab", "Project Files")}
<span className="documents-tab-count">{projectFiles.length}</span>
</button>
<button
className={`btn documents-tab${activeTab === "tasks" ? " active" : ""}`}
role="tab"
aria-selected={activeTab === "tasks"}
aria-label={t("documents.showTaskDocuments", "Show task documents")}
onClick={() => handleTabChange("tasks")}
>
{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" && (
<button
className="btn btn-sm documents-hidden-toggle"
onClick={() => setShowHiddenProjectFiles((prev) => !prev)}
aria-pressed={showHiddenProjectFiles}
aria-label={showHiddenProjectFiles ? t("documents.hideHidden", "Hide hidden project files") : t("documents.showHidden", "Show hidden project files")}
title={showHiddenProjectFiles ? t("documents.hideHiddenFiles", "Hide hidden files") : t("documents.showHiddenFiles", "Show hidden files")}
>
{showHiddenProjectFiles ? <EyeOff size={14} /> : <Eye size={14} />}
{showHiddenProjectFiles ? t("documents.hideHiddenLabel", "Hide Hidden") : t("documents.showHiddenLabel", "Show Hidden")}
</button>
)}
<div className="documents-search">
<Search size={16} className="documents-search-icon" />
<input
type="text"
className="documents-search-input"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={handleSearchChange}
aria-label={searchPlaceholder}
/>
{searchQuery && (
<button
className="documents-search-clear"
onClick={clearSearch}
aria-label={t("documents.clearSearch", "Clear search")}
>
<X size={16} />
</button>
)}
</div>
</div>
</div>
<div className="documents-view-content">
{activeError ? (
<div className="documents-view-error">
<p>{t("documents.failedToLoad", "Failed to load {{type}}: {{error}}", { type: activeTab === "project" ? t("documents.projectFiles", "project files") : activeTab === "tasks" ? t("documents.taskDocuments", "task documents") : t("documents.artifacts", "artifacts"), error: activeError })}</p>
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label={t("documents.retryLoading", "Retry loading documents")}>
<RefreshCw size={16} />
{t("documents.retry", "Retry")}
</button>
</div>
) : activeTab === "project" ? (
projectFilesLoading && projectFiles.length === 0 ? (
<div className="documents-view-loading">
<p><LoadingSpinner label={t("documents.loadingProjectFiles", "Loading project markdown files…")} /></p>
</div>
) : filteredProjectFiles.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>{t("documents.noMatchProject", "No project markdown files match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>{t("documents.noMarkdownFiles", "No Markdown files found in this project.")}</p>
</>
)}
</div>
) : (
<div className={`documents-project-layout${isMobile ? " documents-project-layout--mobile" : ""}`}>
{(!isMobile || !selectedFile) && (
<aside className="documents-view-sidebar" aria-label={t("documents.projectMarkdownFilesLabel", "Project markdown files")}>
<ul className="markdown-file-list">
{filteredProjectFiles.map((file) => {
const isSelected = selectedFile?.path === file.path;
return (
<li key={file.path} className="markdown-file-list-item">
<button
className={`markdown-file-item${isSelected ? " markdown-file-item--selected" : ""}`}
onClick={() => void handleSelectProjectFile(file)}
aria-label={`Open ${file.path}`}
aria-current={isSelected ? "true" : undefined}
>
<span className="markdown-file-item-name">{file.name}</span>
<span className="markdown-file-item-path">{file.path}</span>
<span className="markdown-file-item-meta">
{formatFileSize(file.size)} · {formatTimestamp(file.mtime)}
</span>
</button>
</li>
);
})}
</ul>
</aside>
)}
{(!isMobile || selectedFile) && (
<section className="documents-view-main" aria-label={t("documents.projectFilePreviewLabel", "Project file content preview")}>
{isMobile && selectedFile && (
<button
className="btn btn-sm documents-mobile-back"
onClick={handleBackToFileList}
aria-label={t("documents.backToFilesList", "Back to project files list")}
>
<ArrowLeft size={14} />
{t("documents.backToFiles", "Back to files")}
</button>
)}
{!selectedFile ? (
<div className="documents-view-empty">
<p>{t("documents.selectFile", "Select a Markdown file to view its content.")}</p>
</div>
) : (
<div className="documents-content-viewer">
<div className="documents-content-header">
<p className="documents-file-path-header">{selectedFile.path}</p>
<button
className="btn btn-sm document-mode-toggle"
onClick={() => setRenderProjectMarkdown((prev) => !prev)}
aria-label={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
aria-pressed={renderProjectMarkdown}
title={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
>
{renderProjectMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
</button>
</div>
{fileLoading ? (
<p className="documents-content-state"><LoadingSpinner label={t("documents.loadingFileContent", "Loading file content…")} /></p>
) : fileError ? (
<p className="documents-content-state documents-content-state--error">{fileError}</p>
) : renderProjectMarkdown ? (
<div ref={markdownPreviewRef} className="documents-content-markdown">
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{fileContent ?? ""}</ReactMarkdown>
</div>
</div>
) : (
<pre ref={plainPreviewRef} className="document-card-content-text documents-content-viewer-text">{fileContent ?? ""}</pre>
)}
{selectionPopover}
</div>
)}
</section>
)}
</div>
)
) : activeTab === "artifacts" ? (
artifactsLoading && artifacts.length === 0 ? (
<div className="documents-view-loading">
<p>{t("documents.loadingArtifacts", "Loading artifacts…")}</p>
</div>
) : artifacts.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>{t("documents.noMatchArtifacts", "No artifacts match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>{t("documents.noArtifacts", "No artifacts yet.")}</p>
<p className="documents-view-empty-hint">
{t("documents.artifactsCreatedBy", "Artifacts are created by agents, users, and system tools.")}
</p>
</>
)}
</div>
) : (
/*
FNXC:ArtifactRegistry 2026-06-21-04:46:
The gallery must render all artifact media classes in one responsive surface: images, video, audio, inline documents, and generic file links keep their task and author context visible.
*/
<div className={`documents-artifact-gallery${isMobile ? " documents-artifact-gallery--mobile" : ""}`}>
{artifacts.map((artifact) => (
<ArtifactCard
key={artifact.id}
artifact={artifact}
projectId={projectId}
onOpenTask={handleOpenArtifactTask}
onExpandMedia={handleExpandArtifact}
/>
))}
</div>
)
) : documentsLoading && documents.length === 0 ? (
<div className="documents-view-loading">
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
</div>
) : groupedDocuments.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>{t("documents.noMatchTask", "No task documents match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>{t("documents.noTaskDocuments", "No task documents yet.")}</p>
<p className="documents-view-empty-hint">
{t("documents.documentsCreatedIn", "Documents are created in task detail tabs.")}
</p>
</>
)}
</div>
) : (
<div className="documents-task-list-wrap">
<div className="documents-view-list">
{groupedDocuments.map(({ taskId, taskTitle, taskColumn, documents: taskDocs }) => (
<TaskGroup
key={taskId}
taskId={taskId}
taskTitle={taskTitle}
taskColumn={taskColumn}
documents={taskDocs}
onOpenTask={handleOpenTask}
renderMarkdownStates={taskDocMarkdownStates}
onToggleMarkdown={handleToggleTaskDocMarkdown}
/>
))}
</div>
</div>
)}
</div>
{lightboxArtifact && (
<div
className="modal-overlay open documents-artifact-lightbox-overlay"
role="dialog"
aria-modal="true"
aria-label={t("documents.lightboxLabel", "Artifact media preview")}
onClick={handleLightboxOverlayClick}
>
{/* FNXC:ArtifactRegistry 2026-06-21-23:22: The lightbox reuses the shared modal overlay pattern so image/video artifacts can expand full-size and dismiss by close button, backdrop, or Escape on desktop and mobile. */}
<div className="documents-artifact-lightbox" onClick={(event) => event.stopPropagation()}>
<div className="documents-artifact-lightbox-header">
<h3 className="documents-artifact-lightbox-title">{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}</h3>
<button ref={lightboxCloseRef} className="modal-close" onClick={handleCloseLightbox} aria-label={t("documents.closeLightbox", "Close artifact preview")}>
<X size={20} />
</button>
</div>
<div className="documents-artifact-lightbox-media-frame">
{lightboxArtifact.type === "image" ? (
<img
className="documents-artifact-lightbox-media"
src={artifactMediaUrl(lightboxArtifact.id, projectId)}
alt={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}
/>
) : (
<video
className="documents-artifact-lightbox-media"
src={artifactMediaUrl(lightboxArtifact.id, projectId)}
controls
autoPlay
aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title: lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact") })}
/>
)}
</div>
</div>
</div>
)}
</div>
);
}