feat(dashboard): editable task documents & project files in Artifacts view; fix Add-comment CSS-order regression
- Task Documents right pane gains in-place editing with the shared CodeMirror FileEditor (Save via PUT /tasks/:id/documents/:key); task documents now render markdown by default. - Project Files pane is editable the same way via the project workspace file API, replacing the Read-only badge contract. - Fix "Add comment" doing nothing again: `.selection-comment-trigger:active` tied the global `.btn:active` at (0,2,0) and lost to a bundle-order flip, teleporting the trigger mid-press so click never fired. `:active` rules now use `.btn.selection-comment-trigger` (0,3,0); regression test asserts the prefix so a plain-selector revert fails. - Align the task-document header: path box and Plain/Edit (Cancel/Save) actions share one row, meta line sits below. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/artifacts-doc-editing-and-comment-fix.md
Normal file
7
.changeset/artifacts-doc-editing-and-comment-fix.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Task documents and project files are now editable in the Artifacts view, task documents render markdown by default, and the select-to-comment "Add comment" button works again.
|
||||
category: feature
|
||||
dev: DocumentsView embeds the shared CodeMirror FileEditor for task-document (PUT /tasks/:id/documents/:key) and project-file (project workspace file API) edits. The Add comment no-op was a CSS bundle-order regression — `.btn:active` out-ordered the equal-specificity trigger rule; the `:active` rules now use `.btn.selection-comment-trigger` (0,3,0) with a test asserting the prefix.
|
||||
@@ -305,6 +305,29 @@ Artifacts controls are the first page content below the shared header, so add a
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-11-13:40:
|
||||
Task-document header actions group the Markdown/Plain toggle with the new Edit (and Cancel/Save while editing) buttons so the in-place CodeMirror editing controls stay in the header instead of a second toolbar. The editor container mirrors .artifacts-gallery-viewer-editor sizing so the embedded FileEditor gets real height inside the right pane.
|
||||
*/
|
||||
.documents-task-document-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.documents-task-document-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 45dvh;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.documents-task-document-editor .file-editor-container {
|
||||
flex: 1;
|
||||
min-height: 40dvh;
|
||||
}
|
||||
|
||||
.documents-file-path-header {
|
||||
margin: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
@@ -318,19 +341,6 @@ Artifacts controls are the first page content below the shared header, so add a
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsView 2026-07-10-16:10:
|
||||
Subtle view-only indicator for the project-file preview header; muted so it informs without competing with the Plain/Markdown toggle. Shared markup serves desktop and mobile.
|
||||
*/
|
||||
.documents-readonly-badge {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.documents-content-viewer-text {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -409,18 +419,15 @@ FN-7811 reuses the Project Files shell for Task Documents, while task-scoped sid
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.documents-task-document-title-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-11-14:30:
|
||||
The meta line (author · revision · date) renders as its own row directly under the path+actions header row; the viewer column's gap is too tall between two related header rows, so the meta pulls itself closer to the path box it annotates.
|
||||
*/
|
||||
.documents-task-document-meta {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
flex-wrap: wrap;
|
||||
margin-top: calc(-1 * var(--space-sm));
|
||||
}
|
||||
|
||||
.documents-group-task-id {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import "./DocumentsView.css";
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, FileText, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
|
||||
import { ArrowLeft, FileText, Pencil, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, 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 { FileEditor } from "./FileEditor";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { ArtifactsGallery } from "./ArtifactsGallery";
|
||||
import { ViewHeader } from "./ViewHeader";
|
||||
@@ -73,8 +74,25 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
const taskDocPlainPreviewRef = 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)
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-11-14:45:
|
||||
Markdown render toggles per task document card (scoped by doc ID). Operator requirement: task documents default to the RENDERED MARKDOWN view (not plain text) — the map only stores explicit toggles away from that default, so every `?? true` fallback here and in the toggle handler must stay in sync.
|
||||
*/
|
||||
const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState<Map<string, boolean>>(new Map());
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-11-13:40:
|
||||
Operator requirement: task documents in the Artifacts view must be editable in place with the same CodeMirror FileEditor used for workspace files and artifact docs — the FN-7811 read-only pane is not enough. Editing state is scoped to the selected document ID so switching documents, tabs, or projects can never save a draft against the wrong document; the draft lives here (not in FileEditor) so Save can PUT it via putTaskDocument and refresh the SWR document list.
|
||||
*/
|
||||
const [editingTaskDocumentId, setEditingTaskDocumentId] = useState<string | null>(null);
|
||||
const [taskDocDraft, setTaskDocDraft] = useState("");
|
||||
const [taskDocSaving, setTaskDocSaving] = useState(false);
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-11-14:45:
|
||||
Operator requirement: Project Files must be editable in place too (same CodeMirror FileEditor), replacing the former Read-only badge contract. Saves go through the workspace file API for the "project" workspace and update the local preview content on success.
|
||||
*/
|
||||
const [editingProjectFile, setEditingProjectFile] = useState(false);
|
||||
const [projectFileDraft, setProjectFileDraft] = useState("");
|
||||
const [projectFileSaving, setProjectFileSaving] = useState(false);
|
||||
const [selectionCommentOpen, setSelectionCommentOpen] = useState(false);
|
||||
const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen });
|
||||
const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen });
|
||||
@@ -136,6 +154,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setFileLoading(false);
|
||||
setRenderProjectMarkdown(false);
|
||||
setTaskDocMarkdownStates(new Map());
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
setTaskDocSaving(false);
|
||||
setEditingProjectFile(false);
|
||||
setProjectFileDraft("");
|
||||
setProjectFileSaving(false);
|
||||
}, [projectId]);
|
||||
|
||||
const groupedDocuments = useMemo(() => {
|
||||
@@ -225,6 +249,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setActiveTab(tab);
|
||||
if (tab !== "tasks") {
|
||||
setSelectedTaskDocumentId(null);
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
}
|
||||
if (tab !== "project") {
|
||||
setEditingProjectFile(false);
|
||||
setProjectFileDraft("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -258,6 +288,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setFileLoading(true);
|
||||
setFileError(null);
|
||||
setFileContent(null);
|
||||
setEditingProjectFile(false);
|
||||
setProjectFileDraft("");
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
@@ -288,25 +320,85 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setFileContent(null);
|
||||
setFileError(null);
|
||||
setFileLoading(false);
|
||||
setEditingProjectFile(false);
|
||||
setProjectFileDraft("");
|
||||
}, []);
|
||||
|
||||
const handleStartProjectFileEdit = useCallback(() => {
|
||||
if (fileContent === null) return;
|
||||
setProjectFileDraft(fileContent);
|
||||
setEditingProjectFile(true);
|
||||
}, [fileContent]);
|
||||
|
||||
const handleCancelProjectFileEdit = useCallback(() => {
|
||||
setEditingProjectFile(false);
|
||||
setProjectFileDraft("");
|
||||
}, []);
|
||||
|
||||
const handleSaveProjectFileEdit = useCallback(async () => {
|
||||
if (!selectedFile) return;
|
||||
setProjectFileSaving(true);
|
||||
try {
|
||||
await saveWorkspaceFileContent("project", selectedFile.path, projectFileDraft, projectId);
|
||||
setFileContent(projectFileDraft);
|
||||
setEditingProjectFile(false);
|
||||
setProjectFileDraft("");
|
||||
addToast(t("documents.projectFileSaved", "File saved"), "success");
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setProjectFileSaving(false);
|
||||
}
|
||||
}, [selectedFile, projectFileDraft, projectId, addToast, t]);
|
||||
|
||||
const handleSelectTaskDocument = useCallback((docId: string) => {
|
||||
setSelectedTaskDocumentId(docId);
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
}, []);
|
||||
|
||||
const handleBackToTaskDocumentList = useCallback(() => {
|
||||
setSelectedTaskDocumentId(null);
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
}, []);
|
||||
|
||||
const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
|
||||
setTaskDocMarkdownStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(docId) ?? false;
|
||||
const current = next.get(docId) ?? true;
|
||||
next.set(docId, !current);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStartTaskDocEdit = useCallback(() => {
|
||||
if (!selectedTaskDocument) return;
|
||||
setTaskDocDraft(selectedTaskDocument.content);
|
||||
setEditingTaskDocumentId(selectedTaskDocument.id);
|
||||
}, [selectedTaskDocument]);
|
||||
|
||||
const handleCancelTaskDocEdit = useCallback(() => {
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
}, []);
|
||||
|
||||
const handleSaveTaskDocEdit = useCallback(async () => {
|
||||
if (!selectedTaskDocument) return;
|
||||
setTaskDocSaving(true);
|
||||
try {
|
||||
await putTaskDocument(selectedTaskDocument.taskId, selectedTaskDocument.key, taskDocDraft, {}, projectId);
|
||||
await refreshDocuments();
|
||||
setEditingTaskDocumentId(null);
|
||||
setTaskDocDraft("");
|
||||
addToast(t("documents.taskDocumentSaved", "Document saved"), "success");
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setTaskDocSaving(false);
|
||||
}
|
||||
}, [selectedTaskDocument, taskDocDraft, projectId, refreshDocuments, addToast, t]);
|
||||
|
||||
const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
@@ -322,9 +414,10 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
|
||||
|
||||
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? documents.length : artifacts.length;
|
||||
const selectedTaskDocumentRendersMarkdown = selectedTaskDocument ? (taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) : false;
|
||||
const selectedTaskDocumentRendersMarkdown = selectedTaskDocument ? (taskDocMarkdownStates.get(selectedTaskDocument.id) ?? true) : false;
|
||||
const activeTaskDocumentSelection = selectedTaskDocumentRendersMarkdown ? taskDocMarkdownSelection : taskDocPlainSelection;
|
||||
const selectionPopover = activeTab === "project" && selectedFile && onSendSelectionToTask && activeProjectSelection ? (
|
||||
const editingSelectedTaskDocument = selectedTaskDocument !== null && editingTaskDocumentId === selectedTaskDocument.id;
|
||||
const selectionPopover = activeTab === "project" && selectedFile && !editingProjectFile && onSendSelectionToTask && activeProjectSelection ? (
|
||||
<SelectionCommentPopover
|
||||
selectedText={activeProjectSelection.selectedText}
|
||||
anchorRect={activeProjectSelection.anchorRect}
|
||||
@@ -333,7 +426,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
onOpenChange={setSelectionCommentOpen}
|
||||
/>
|
||||
) : null;
|
||||
const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? (
|
||||
const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && !editingSelectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? (
|
||||
<SelectionCommentPopover
|
||||
selectedText={activeTaskDocumentSelection.selectedText}
|
||||
anchorRect={activeTaskDocumentSelection.anchorRect}
|
||||
@@ -516,29 +609,56 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
<div className="documents-content-header">
|
||||
<p className="documents-file-path-header">{selectedFile.path}</p>
|
||||
{/*
|
||||
FNXC:ArtifactsView 2026-07-10-16:10:
|
||||
First-run review feedback: the Artifacts preview pane did not communicate whether documents are editable. This pane is view-only (there is no editor here — editing happens elsewhere, e.g. the workspace FileEditor), so a persistent Read-only badge states that explicitly on both desktop and mobile. Select-to-comment still works and is the intended interaction.
|
||||
FNXC:DocumentsView 2026-07-11-14:45:
|
||||
Operator requirement: Project Files are editable in place with the shared CodeMirror FileEditor, replacing the former Read-only badge contract (the FN-7810-era badge said "editing happens elsewhere"; it now happens here). While editing, the Markdown/Plain toggle is replaced by Cancel/Save and select-to-comment is suppressed.
|
||||
*/}
|
||||
<span
|
||||
className="documents-readonly-badge badge"
|
||||
title={t("documents.readOnlyHint", "This preview is read-only. Select text to comment and send it to a new task.")}
|
||||
>
|
||||
{t("documents.readOnly", "Read-only")}
|
||||
</span>
|
||||
<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 className="documents-task-document-actions">
|
||||
{editingProjectFile ? (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={handleCancelProjectFileEdit} disabled={projectFileSaving}>
|
||||
{t("documents.cancelEdit", "Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleSaveProjectFileEdit()} disabled={projectFileSaving}>
|
||||
{projectFileSaving ? t("documents.saving", "Saving…") : t("documents.saveProjectFile", "Save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleStartProjectFileEdit}
|
||||
disabled={fileLoading || fileError !== null || fileContent === null}
|
||||
aria-label={t("documents.editProjectFile", "Edit project file")}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
{t("documents.edit", "Edit")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
) : editingProjectFile ? (
|
||||
<div className="documents-task-document-editor" aria-label={t("documents.projectFileContentEditor", "Project file content editor")}>
|
||||
<FileEditor
|
||||
content={projectFileDraft}
|
||||
onChange={setProjectFileDraft}
|
||||
filePath={selectedFile.path}
|
||||
forceToolbarActionsVisible
|
||||
/>
|
||||
</div>
|
||||
) : renderProjectMarkdown ? (
|
||||
<div ref={markdownPreviewRef} className="documents-content-markdown">
|
||||
<div className="markdown-body">
|
||||
@@ -690,28 +810,62 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
</div>
|
||||
) : (
|
||||
<div className="documents-content-viewer documents-task-document-viewer">
|
||||
{/*
|
||||
FNXC:DocumentsView 2026-07-11-14:30:
|
||||
The header actions (Plain/Edit, or Cancel/Save while editing) must sit on the same row as the document path box, vertically centered against it — when the path and the author/revision meta shared a title-block column, the actions centered on the two-row block and rendered visibly below the path box's midline (user-reported misalignment). The meta line now renders below the path+actions row at full width.
|
||||
*/}
|
||||
<div className="documents-content-header documents-task-document-header">
|
||||
<div className="documents-task-document-title-block">
|
||||
<p className="documents-file-path-header">{selectedTaskDocument.taskId} / {selectedTaskDocument.key}</p>
|
||||
<div className="document-card-meta documents-task-document-meta">
|
||||
<span className="document-card-author">{selectedTaskDocument.author}</span>
|
||||
<span className="document-card-separator">·</span>
|
||||
<span>{t("documents.revisionShort", "v{{revision}}", { revision: selectedTaskDocument.revision })}</span>
|
||||
<span className="document-card-separator">·</span>
|
||||
<span className="document-card-date">{formatTimestamp(selectedTaskDocument.updatedAt)}</span>
|
||||
</div>
|
||||
<p className="documents-file-path-header">{selectedTaskDocument.taskId} / {selectedTaskDocument.key}</p>
|
||||
{/*
|
||||
FNXC:DocumentsView 2026-07-11-13:40:
|
||||
Operator requirement: task documents must be editable in place from the Artifacts view with the shared CodeMirror FileEditor (not a plain textarea, and not read-only). While editing, the Markdown/Plain toggle is replaced by Cancel/Save (FileEditor carries its own Edit/Preview toolbar for markdown) and select-to-comment is suppressed so the composer lock cannot fight the editor selection.
|
||||
*/}
|
||||
<div className="documents-task-document-actions">
|
||||
{editingSelectedTaskDocument ? (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={handleCancelTaskDocEdit} disabled={taskDocSaving}>
|
||||
{t("documents.cancelEdit", "Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleSaveTaskDocEdit()} disabled={taskDocSaving}>
|
||||
{taskDocSaving ? t("documents.saving", "Saving…") : t("documents.saveTaskDocument", "Save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={() => handleToggleTaskDocMarkdown(selectedTaskDocument.id)}
|
||||
aria-label={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
aria-pressed={selectedTaskDocumentRendersMarkdown}
|
||||
title={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
>
|
||||
{selectedTaskDocumentRendersMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={handleStartTaskDocEdit} aria-label={t("documents.editTaskDocument", "Edit task document")}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
{t("documents.edit", "Edit")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={() => handleToggleTaskDocMarkdown(selectedTaskDocument.id)}
|
||||
aria-label={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
aria-pressed={selectedTaskDocumentRendersMarkdown}
|
||||
title={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
>
|
||||
{selectedTaskDocumentRendersMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
|
||||
</button>
|
||||
</div>
|
||||
{selectedTaskDocumentRendersMarkdown ? (
|
||||
<div className="document-card-meta documents-task-document-meta">
|
||||
<span className="document-card-author">{selectedTaskDocument.author}</span>
|
||||
<span className="document-card-separator">·</span>
|
||||
<span>{t("documents.revisionShort", "v{{revision}}", { revision: selectedTaskDocument.revision })}</span>
|
||||
<span className="document-card-separator">·</span>
|
||||
<span className="document-card-date">{formatTimestamp(selectedTaskDocument.updatedAt)}</span>
|
||||
</div>
|
||||
{editingSelectedTaskDocument ? (
|
||||
<div className="documents-task-document-editor" aria-label={t("documents.taskDocumentContentEditor", "Task document content editor")}>
|
||||
<FileEditor
|
||||
content={taskDocDraft}
|
||||
onChange={setTaskDocDraft}
|
||||
filePath={selectedTaskDocument.key.includes(".") ? selectedTaskDocument.key : `${selectedTaskDocument.key}.md`}
|
||||
forceToolbarActionsVisible
|
||||
/>
|
||||
</div>
|
||||
) : selectedTaskDocumentRendersMarkdown ? (
|
||||
<div ref={taskDocMarkdownPreviewRef} className="documents-content-markdown">
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedTaskDocument.content}</ReactMarkdown>
|
||||
|
||||
@@ -25,8 +25,16 @@ Every :active override here must restate the positioning translate alongside the
|
||||
feedback so the trigger stays under the cursor for the whole press. This one shared rule
|
||||
covers all popover surfaces: DocumentsView project-file preview (plain and markdown) and
|
||||
FileEditor (editor and preview), desktop and mobile.
|
||||
|
||||
FNXC:ArtifactsView 2026-07-11-14:20:
|
||||
Regression: `.selection-comment-trigger:active` and the global `.btn:active` are both
|
||||
specificity (0,2,0), so this fix silently lost to `.btn:active` when the CSS bundle order
|
||||
flipped and the mid-press teleport/no-op came back (same bundle-order failure mode the
|
||||
`.selection-comment-panel.card` rule below documents). `.btn.selection-comment-trigger:active`
|
||||
is (0,3,0) so the positioning translate wins regardless of bundle order. Never rely on
|
||||
source order against `.btn:active` here.
|
||||
*/
|
||||
.selection-comment-trigger:active {
|
||||
.btn.selection-comment-trigger:active {
|
||||
transform: translate(-50%, calc(-1 * var(--space-xl))) scale(0.97);
|
||||
}
|
||||
|
||||
@@ -108,8 +116,9 @@ a selection near the bottom of the viewport from pushing the composer off-screen
|
||||
Mobile uses a taller lift, so its :active rule must restate the mobile translate too —
|
||||
otherwise the desktop :active rule (space-xl) would snap the pressed trigger to the
|
||||
desktop offset and reintroduce the missed-click no-op on touch/landscape-phone widths.
|
||||
FNXC:ArtifactsView 2026-07-11-14:20: `.btn.` prefix for bundle-order immunity — see the desktop rule above.
|
||||
*/
|
||||
.selection-comment-trigger:active {
|
||||
.btn.selection-comment-trigger:active {
|
||||
transform: translate(-50%, calc(-1 * var(--space-2xl))) scale(0.97);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import { DocumentsView } from "../DocumentsView";
|
||||
import { fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, updateArtifact } from "../../api";
|
||||
import { fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, updateArtifact } from "../../api";
|
||||
import { useArtifacts } from "../../hooks/useArtifacts";
|
||||
import { useDocuments } from "../../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
|
||||
@@ -15,6 +15,8 @@ vi.mock("../../api", () => ({
|
||||
fetchArtifacts: vi.fn(),
|
||||
fetchArtifact: vi.fn(),
|
||||
updateArtifact: vi.fn(),
|
||||
putTaskDocument: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
|
||||
}));
|
||||
|
||||
@@ -47,6 +49,8 @@ const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
|
||||
const mockFetchArtifact = vi.mocked(fetchArtifact);
|
||||
const mockUpdateArtifact = vi.mocked(updateArtifact);
|
||||
const mockPutTaskDocument = vi.mocked(putTaskDocument);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
|
||||
|
||||
function mockSelectionRect() {
|
||||
const rect = new DOMRect(10, 20, 80, 12);
|
||||
@@ -775,23 +779,48 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsView 2026-07-10-16:20:
|
||||
First-run review: the preview pane did not communicate that it is view-only. The Read-only
|
||||
badge must render with the preview header in BOTH render modes (plain and markdown) — the
|
||||
header markup is shared between desktop and mobile layouts.
|
||||
FNXC:DocumentsView 2026-07-11-14:45:
|
||||
Operator requirement: Project Files are editable in place with the shared CodeMirror FileEditor (this replaced the former Read-only badge contract). Edit swaps the preview for the editor, Save persists via the "project" workspace file API and updates the preview, Cancel discards without saving, and select-to-comment is suppressed while editing.
|
||||
*/
|
||||
it("shows a Read-only badge in the project file preview header in plain and markdown modes", async () => {
|
||||
it("edits a project file in the shared file editor and saves via the workspace file API", async () => {
|
||||
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true } as Awaited<ReturnType<typeof saveWorkspaceFileContent>>);
|
||||
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/);
|
||||
expect(screen.getByText("Read-only")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Read-only")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
await screen.findByText("Hello docs");
|
||||
expect(screen.getByText("Read-only")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit project file/i }));
|
||||
const editor = screen.getByLabelText("file editor");
|
||||
expect(editor).toHaveValue("# README\nHello docs");
|
||||
fireEvent.change(editor, { target: { value: "# README\nUpdated docs" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith("project", "README.md", "# README\nUpdated docs", undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("file editor")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Updated docs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels project file editing without saving", async () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
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/);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit project file/i }));
|
||||
fireEvent.change(screen.getByLabelText("file editor"), { target: { value: "Discarded" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
|
||||
expect(screen.queryByLabelText("file editor")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Hello docs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends selected plain project file preview text to a new task description", async () => {
|
||||
@@ -847,6 +876,8 @@ describe("DocumentsView", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
// Markdown is the default render mode; switch to plain to exercise the plain-preview selection surface.
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to plain text/i }));
|
||||
const plainPreview = screen.getByText("Alpha document content");
|
||||
selectNodeText(plainPreview);
|
||||
|
||||
@@ -866,7 +897,7 @@ describe("DocumentsView", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
// Markdown is the default render mode — no toggle needed.
|
||||
const markdownPreviewText = await screen.findByText("Alpha document content");
|
||||
selectNodeText(markdownPreviewText);
|
||||
|
||||
@@ -928,6 +959,53 @@ describe("DocumentsView", () => {
|
||||
expect(await screen.findByRole("button", { name: /add a comment/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-11-13:40:
|
||||
Operator requirement: task documents must be editable in place from the Artifacts view with the shared CodeMirror FileEditor. Surface enumeration for the affordance: Edit swaps the preview for the editor, Save persists via putTaskDocument and refreshes the list, Cancel restores the read view without saving, and select-to-comment is suppressed while editing so the composer cannot fight the editor selection.
|
||||
*/
|
||||
it("edits a task document in the shared file editor and saves via putTaskDocument", async () => {
|
||||
mockPutTaskDocument.mockResolvedValue(mockTaskDocuments[0]);
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit task document/i }));
|
||||
|
||||
const editor = screen.getByLabelText("file editor");
|
||||
expect(editor).toHaveValue("Alpha document content");
|
||||
fireEvent.change(editor, { target: { value: "Updated document content" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPutTaskDocument).toHaveBeenCalledWith("KB-001", "plan", "Updated document content", {}, undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("file editor")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Document saved", "success");
|
||||
});
|
||||
|
||||
it("cancels task document editing without saving and suppresses select-to-comment while editing", async () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
selectNodeText(screen.getByText("Alpha document content"));
|
||||
expect(await screen.findByRole("button", { name: /add a comment/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit task document/i }));
|
||||
expect(screen.queryByRole("button", { name: /add a comment/i })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("file editor"), { target: { value: "Discarded draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockPutTaskDocument).not.toHaveBeenCalled();
|
||||
expect(screen.queryByLabelText("file editor")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("search filters task documents and clears filtered-out selection", async () => {
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
@@ -1192,15 +1270,15 @@ describe("DocumentsView", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
|
||||
// Task document toggle should default to raw (not influenced by project toggle)
|
||||
const taskToggle = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(taskToggle).toHaveAttribute("aria-pressed", "false");
|
||||
// Task document toggle defaults to markdown (its own default, not influenced by project toggle)
|
||||
const taskToggle = screen.getByRole("button", { name: /switch to plain text/i });
|
||||
expect(taskToggle).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Toggle task document
|
||||
// Toggle task document to plain
|
||||
fireEvent.click(taskToggle);
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: /switch to markdown/i })).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Switch back to project - project toggle should still be on
|
||||
// Switch back to project - project toggle should still be on markdown
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
@@ -1220,15 +1298,15 @@ describe("DocumentsView", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
|
||||
// Should show raw text by default
|
||||
// FNXC:DocumentsView 2026-07-11-14:45: operator requirement — task documents render markdown by default.
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
const toggleBtn = screen.getByRole("button", { name: /switch to plain text/i });
|
||||
expect(toggleBtn).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Toggle should exist
|
||||
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Click to toggle to markdown mode
|
||||
// Click to toggle to plain mode and back
|
||||
fireEvent.click(toggleBtn);
|
||||
expect(screen.getByRole("button", { name: /switch to markdown/i })).toHaveAttribute("aria-pressed", "false");
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,12 @@ describe("SelectionCommentPopover", () => {
|
||||
must include the `translate(-50%` positioning component. The popover is shared by
|
||||
DocumentsView (plain + markdown preview) and FileEditor (editor + preview), so this one
|
||||
stylesheet invariant covers all surfaces.
|
||||
|
||||
FNXC:ArtifactsView 2026-07-11-14:20:
|
||||
The bug regressed a second way: `.selection-comment-trigger:active` ties `.btn:active` at
|
||||
specificity (0,2,0), so a CSS bundle-order flip let `.btn:active` win again and the no-op
|
||||
came back. Every :active trigger rule must now carry the `.btn.` prefix ((0,3,0)) so it
|
||||
out-specifies `.btn:active` regardless of bundle order — asserted below.
|
||||
*/
|
||||
it("keeps the positioning translate in every trigger transform, including :active press state", () => {
|
||||
const css = readFileSync(join(__dirname, "..", "SelectionCommentPopover.css"), "utf8");
|
||||
@@ -87,7 +93,7 @@ describe("SelectionCommentPopover", () => {
|
||||
const rulePattern = /([^{}]+)\{([^{}]*)\}/g;
|
||||
for (const match of uncommented.matchAll(rulePattern)) {
|
||||
const selector = match[1].trim();
|
||||
if (selector.split(",").some((part) => part.trim().startsWith(".selection-comment-trigger"))) {
|
||||
if (selector.split(",").some((part) => part.trim().includes(".selection-comment-trigger"))) {
|
||||
triggerBlocks.push({ selector, block: match[2] });
|
||||
}
|
||||
}
|
||||
@@ -102,6 +108,13 @@ describe("SelectionCommentPopover", () => {
|
||||
|
||||
const activeBlocks = transformBlocks.filter(({ selector }) => selector.includes(":active"));
|
||||
expect(activeBlocks.length, "both desktop and mobile need an :active override that restates the translate").toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (const { selector } of activeBlocks) {
|
||||
expect(
|
||||
selector.includes(".btn.selection-comment-trigger"),
|
||||
`":active" trigger rule "${selector}" must use the .btn. prefix to out-specify the global .btn:active regardless of CSS bundle order`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user