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, 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"; 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[]; onOpenTask: (taskId: string) => void; renderMarkdownStates: Map; 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 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 (
{document.key} v{document.revision}
{document.author} · {formatTimestamp(document.updatedAt)}
{expanded ? ( <>
{renderMarkdown ? (
{document.content}
) : (
{document.content}
)} ) : (

{preview}

)} {showExpand && !expanded && (

…

)}
); } function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) { const { t } = useTranslation("app"); const [expanded, setExpanded] = useState(false); return (
{t("documents.docCount", "{{count}} doc{{plural}}", { count: documents.length, plural: documents.length !== 1 ? "s" : "" })}
{expanded && (
{documents.map((doc) => ( onToggleMarkdown(doc.id)} /> ))}
)}
); } 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) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onExpandMedia(artifact); } }, [artifact, onExpandMedia]); return (
{isExpandableMedia ? (
onExpandMedia(artifact)} onKeyDown={handleExpandKeyDown} > {artifact.type === "image" ? ( {title} ) : (
) : (
)}
{typeLabel} {artifact.authorId}

{title}

{artifact.description &&

{artifact.description}

}
{formatTimestamp(artifact.createdAt)} {artifact.sizeBytes !== undefined && {formatFileSize(artifact.sizeBytes)}}
{artifact.taskId && ( )}
); } export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) { const { t } = useTranslation("app"); const [activeTab, setActiveTab] = useState("project"); const [searchQuery, setSearchQuery] = useState(""); const [selectedFile, setSelectedFile] = useState(null); const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false); const [fileContent, setFileContent] = useState(null); const [fileLoading, setFileLoading] = useState(false); const [fileError, setFileError] = useState(null); const [isMobile, setIsMobile] = useState(false); const requestIdRef = useRef(0); const initialTabSetRef = useRef(false); const markdownPreviewRef = useRef(null); const plainPreviewRef = useRef(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>(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(null); const lightboxCloseRef = useRef(null); const lightboxReturnFocusRef = useRef(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(); 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, 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) => { 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) => { 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 ? ( ) : 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 (
{/* 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. */}
{t("documents.resultCount", "{{count}} result{{plural}}", { count: activeCount, plural: activeCount !== 1 ? "s" : "" })} )} />
{/* 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. */}
{activeTab === "project" && ( )}
{searchQuery && ( )}
{activeError ? (

{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 })}

) : activeTab === "project" ? ( projectFilesLoading && projectFiles.length === 0 ? (

) : filteredProjectFiles.length === 0 ? (
{searchQuery.trim() ? (

{t("documents.noMatchProject", "No project markdown files match \"{{query}}\".", { query: searchQuery.trim() })}

) : ( <>

{t("documents.noMarkdownFiles", "No Markdown files found in this project.")}

)}
) : (
{(!isMobile || !selectedFile) && ( )} {(!isMobile || selectedFile) && (
{isMobile && selectedFile && ( )} {!selectedFile ? (

{t("documents.selectFile", "Select a Markdown file to view its content.")}

) : (

{selectedFile.path}

{fileLoading ? (

) : fileError ? (

{fileError}

) : renderProjectMarkdown ? (
{fileContent ?? ""}
) : (
{fileContent ?? ""}
)} {selectionPopover}
)}
)}
) ) : activeTab === "artifacts" ? ( artifactsLoading && artifacts.length === 0 ? (

{t("documents.loadingArtifacts", "Loading artifacts…")}

) : artifacts.length === 0 ? (
{searchQuery.trim() ? (

{t("documents.noMatchArtifacts", "No artifacts match \"{{query}}\".", { query: searchQuery.trim() })}

) : ( <>

{t("documents.noArtifacts", "No artifacts yet.")}

{t("documents.artifactsCreatedBy", "Artifacts are created by agents, users, and system tools.")}

)}
) : ( /* 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. */
{artifacts.map((artifact) => ( ))}
) ) : documentsLoading && documents.length === 0 ? (

) : groupedDocuments.length === 0 ? (
{searchQuery.trim() ? (

{t("documents.noMatchTask", "No task documents match \"{{query}}\".", { query: searchQuery.trim() })}

) : ( <>

{t("documents.noTaskDocuments", "No task documents yet.")}

{t("documents.documentsCreatedIn", "Documents are created in task detail tabs.")}

)}
) : (
{groupedDocuments.map(({ taskId, taskTitle, documents: taskDocs }) => ( ))}
)}
{lightboxArtifact && (
{/* 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. */}
event.stopPropagation()}>

{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}

{lightboxArtifact.type === "image" ? ( {lightboxArtifact.title ) : (
)}
); }