import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; import { useTranslation } from "react-i18next"; import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History, X } from "lucide-react"; import "./DocumentsView.css"; import "./TaskDocumentsTab.css"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { ArtifactWithTask, Task, TaskDocument, TaskDocumentRevision } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { fetchTaskDocuments, fetchTaskDocumentRevisions, putTaskDocument, deleteTaskDocument, artifactMediaUrl, } from "../api"; import { useArtifacts } from "../hooks/useArtifacts"; import { LoadingSpinner } from "./LoadingSpinner"; import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia"; // Document key validation: alphanumeric, hyphens, underscores, 1-64 chars const DOCUMENT_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/; const MAX_CONTENT_PREVIEW = 200; const TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY = "fusion.taskDocuments.renderMarkdown"; function readBooleanPref(key: string, defaultValue: boolean): boolean { if (typeof window === "undefined") return defaultValue; try { const raw = window.localStorage.getItem(key); if (raw === null) return defaultValue; return raw === "true"; } catch { return defaultValue; } } function writeBooleanPref(key: string, value: boolean): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, value ? "true" : "false"); } catch { // ignore storage failures (quota, private mode, etc.) } } interface TaskDocumentsTabProps { taskId: string; addToast: (message: string, type?: ToastType) => void; onTaskUpdated?: (task: Task) => void; projectId?: string; canEdit?: boolean; } function formatTimestamp(iso?: string): string { if (!iso) return ""; return new Date(iso).toLocaleString(); } function getContentPreview(content: string, maxLength: number = MAX_CONTENT_PREVIEW): string { if (content.length <= maxLength) return content; return content.substring(0, maxLength) + "…"; } 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`; } interface TaskArtifactCardProps { artifact: ArtifactWithTask; projectId?: string; onExpandImage: (artifact: ArtifactWithTask) => void; } function TaskArtifactCard({ artifact, projectId, onExpandImage }: TaskArtifactCardProps) { 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"); return (
{artifact.type === "image" ? ( ) : (
)}
{typeLabel} {artifact.authorId}
{title}
{artifact.description &&

{artifact.description}

}
{formatTimestamp(artifact.createdAt)} {artifact.sizeBytes !== undefined && {formatFileSize(artifact.sizeBytes)}}
); } export function TaskDocumentsTab({ taskId, addToast, onTaskUpdated: _onTaskUpdated, projectId, canEdit = false, }: TaskDocumentsTabProps) { const { t } = useTranslation("app"); const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); const [expandedDocKeys, setExpandedDocKeys] = useState>(() => new Set()); const [revisionContentByKey, setRevisionContentByKey] = useState>({}); const [editingDocKey, setEditingDocKey] = useState(null); const [editContent, setEditContent] = useState(""); const [showHistory, setShowHistory] = useState(null); const [revisions, setRevisions] = useState([]); const [loadingRevisions, setLoadingRevisions] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); const [newDocKey, setNewDocKey] = useState(""); const [newDocContent, setNewDocContent] = useState(""); const [saving, setSaving] = useState(false); const [deletingKey, setDeletingKey] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); /* * FNXC:ArtifactRegistry 2026-07-11-00:00: * FN-7833 makes task Artifacts-tab documents readable without extra clicks: render Markdown by default and persist the operator's Markdown/Plain preference for future task document views. */ const [renderMarkdown, setRenderMarkdown] = useState(() => readBooleanPref(TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY, true)); /* * FNXC:ArtifactRegistry 2026-06-29-00:00: * Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls. */ const [lightboxArtifact, setLightboxArtifact] = useState(null); const lightboxDialogRef = useRef(null); const lightboxCloseRef = useRef(null); const lightboxReturnFocusRef = useRef(null); const loadedTaskIdRef = useRef(taskId); const documentKeysRef = useRef>(new Set()); const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId }); const loadDocuments = useCallback(async () => { try { const docs = await fetchTaskDocuments(taskId, projectId); const previousKeys = loadedTaskIdRef.current === taskId ? documentKeysRef.current : new Set(); const nextKeys = new Set(docs.map((doc) => doc.key)); loadedTaskIdRef.current = taskId; documentKeysRef.current = nextKeys; setDocuments(docs); setExpandedDocKeys((current) => { const next = new Set(); for (const doc of docs) { if (current.has(doc.key) || !previousKeys.has(doc.key)) { next.add(doc.key); } } return next; }); setRevisionContentByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => nextKeys.has(key)))); } catch (error) { addToast(getErrorMessage(error) || t("taskDocuments.failedToLoad", "Failed to load documents"), "error"); } finally { setLoading(false); } }, [taskId, projectId, addToast]); useEffect(() => { void loadDocuments(); }, [loadDocuments]); useEffect(() => { if (artifactsError) { addToast(artifactsError || t("taskDocuments.failedToLoadArtifacts", "Failed to load artifacts"), "error"); } }, [addToast, artifactsError, t]); useEffect(() => { writeBooleanPref(TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown); }, [renderMarkdown]); function handleExpandDocument(doc: TaskDocument) { const isExpanded = expandedDocKeys.has(doc.key); setExpandedDocKeys((current) => { const next = new Set(current); if (isExpanded) { next.delete(doc.key); } else { next.add(doc.key); } return next; }); if (isExpanded) { if (editingDocKey === doc.key) { setEditingDocKey(null); setEditContent(""); } if (showHistory === doc.key) { setShowHistory(null); setRevisions([]); } } } async function handleToggleHistory(docKey: string) { if (showHistory === docKey) { setShowHistory(null); setRevisions([]); } else { setShowHistory(docKey); setLoadingRevisions(true); try { const revs = await fetchTaskDocumentRevisions(taskId, docKey, projectId); setRevisions(revs); } catch (error) { addToast(getErrorMessage(error) || t("taskDocuments.failedToLoadRevisions", "Failed to load revisions"), "error"); } finally { setLoadingRevisions(false); } } } function handleStartEdit(doc: TaskDocument) { setEditingDocKey(doc.key); setEditContent(revisionContentByKey[doc.key] ?? doc.content); } function handleCancelEdit() { setEditingDocKey(null); setEditContent(""); } async function handleSaveEdit() { if (!editingDocKey || !editContent.trim()) return; setSaving(true); try { await putTaskDocument(taskId, editingDocKey, editContent, {}, projectId); setEditingDocKey(null); setEditContent(""); setRevisionContentByKey((current) => { const next = { ...current }; delete next[editingDocKey]; return next; }); await loadDocuments(); addToast(t("taskDocuments.saved", "Document saved"), "success"); } catch (error) { addToast(getErrorMessage(error) || t("taskDocuments.failedToSave", "Failed to save document"), "error"); } finally { setSaving(false); } } async function handleCreateDocument() { const key = newDocKey.trim(); const content = newDocContent.trim(); // Validate key if (!key) { addToast(t("taskDocuments.keyRequired", "Document key is required"), "error"); return; } if (!DOCUMENT_KEY_REGEX.test(key)) { addToast(t("taskDocuments.invalidKeyFormat", "Invalid key format. Use 1-64 alphanumeric characters, hyphens, or underscores."), "error"); return; } if (!content) { addToast(t("taskDocuments.contentRequired", "Content is required"), "error"); return; } setSaving(true); try { await putTaskDocument(taskId, key, content, {}, projectId); setShowCreateForm(false); setNewDocKey(""); setNewDocContent(""); await loadDocuments(); addToast(t("taskDocuments.created", "Document created"), "success"); } catch (error) { addToast(getErrorMessage(error) || t("taskDocuments.failedToCreate", "Failed to create document"), "error"); } finally { setSaving(false); } } async function handleDeleteDocument(key: string) { setDeletingKey(key); try { await deleteTaskDocument(taskId, key, projectId); setConfirmDelete(null); setDeletingKey(null); setExpandedDocKeys((current) => { const next = new Set(current); next.delete(key); return next; }); setRevisionContentByKey((current) => { const next = { ...current }; delete next[key]; return next; }); if (showHistory === key) { setShowHistory(null); setRevisions([]); } await loadDocuments(); addToast(t("taskDocuments.deleted", "Document deleted"), "success"); } catch (error) { addToast(getErrorMessage(error) || t("taskDocuments.failedToDelete", "Failed to delete document"), "error"); } finally { setDeletingKey(null); } } function handleViewRevision(docKey: string, revision: TaskDocumentRevision) { setRevisionContentByKey((current) => ({ ...current, [docKey]: revision.content })); setEditingDocKey(null); setEditContent(""); } const handleExpandArtifactImage = 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(); return; } if (event.key !== "Tab") { return; } /* * FNXC:ArtifactRegistry 2026-06-29-17:08: * The artifact preview declares an aria-modal dialog, so keyboard focus must stay inside the lightbox until Escape, overlay click, or the close button dismisses it. Cycle Tab/Shift+Tab over current focusable controls instead of letting focus escape into the task-detail modal behind the overlay. */ const dialog = lightboxDialogRef.current; const focusableElements = Array.from(dialog?.querySelectorAll( 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', ) ?? []).filter((element) => element.getAttribute("aria-hidden") !== "true"); if (!dialog || focusableElements.length === 0) { event.preventDefault(); return; } const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const activeElement = document.activeElement; if (event.shiftKey && activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } else if (!event.shiftKey && activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } else if (!dialog.contains(activeElement)) { event.preventDefault(); firstElement.focus(); } }; 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]); if (loading || artifactsLoading) { return (

{t("taskDocuments.heading", "Artifacts")}

); } const isEmpty = documents.length === 0 && artifacts.length === 0 && !showCreateForm; return (

{t("taskDocuments.heading", "Artifacts")}

{isEmpty && (
{t("taskDocuments.noDocuments", "No documents or artifacts yet.")}
)} {/* * FNXC:ArtifactRegistry 2026-06-21-21:44: * The per-task Artifacts tab must surface both traditional task documents and agent-created media artifacts so users can inspect all task-scoped outputs without leaving the task modal. */} {artifacts.length > 0 && (
{t("taskDocuments.artifactsSubheading", "Media artifacts")}
{t("taskDocuments.artifactCount", "{{count}} artifact{{plural}}", { count: artifacts.length, plural: artifacts.length === 1 ? "" : "s" })}
{artifacts.map((artifact) => ( ))}
)}
{t("taskDocuments.documentsSubheading", "Task documents")}
{documents.length > 0 && ( {t("taskDocuments.documentCount", "{{count}} document{{plural}}", { count: documents.length, plural: documents.length === 1 ? "" : "s" })} )}
{/* Create Form */} {showCreateForm && (
{t("taskDocuments.newDocumentTitle", "New Document")}
setNewDocKey(e.target.value)} placeholder={t("taskDocuments.keyPlaceholder", "e.g., plan, notes, research")} disabled={saving} /> {t("taskDocuments.keyHint", "Alphanumeric, hyphens, underscores (1-64 chars)")}