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:
gsxdsm
2026-07-11 19:31:50 -07:00
parent 53427cde2c
commit 05d30ffee7
6 changed files with 358 additions and 90 deletions

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

View File

@@ -305,6 +305,29 @@ Artifacts controls are the first page content below the shared header, so add a
gap: var(--space-sm); 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 { .documents-file-path-header {
margin: 0; margin: 0;
padding: var(--space-sm) var(--space-md); 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; 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 { .documents-content-viewer-text {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); 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; border-left-color: transparent;
} }
.documents-task-document-title-block { /*
display: flex; FNXC:DocumentsView 2026-07-11-14:30:
flex: 1; 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.
min-width: 0; */
flex-direction: column;
gap: var(--space-xs);
}
.documents-task-document-meta { .documents-task-document-meta {
padding: 0; padding: 0;
border-bottom: none; border-bottom: none;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: calc(-1 * var(--space-sm));
} }
.documents-group-task-id { .documents-group-task-id {

View File

@@ -1,17 +1,18 @@
import "./DocumentsView.css"; import "./DocumentsView.css";
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react"; import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { useTranslation } from "react-i18next"; 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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core"; import type { ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast"; 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 { useArtifacts } from "../hooks/useArtifacts";
import { useDocuments } from "../hooks/useDocuments"; import { useDocuments } from "../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles"; import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
import { useSelectionComment } from "../hooks/useSelectionComment"; import { useSelectionComment } from "../hooks/useSelectionComment";
import { SelectionCommentPopover } from "./SelectionCommentPopover"; import { SelectionCommentPopover } from "./SelectionCommentPopover";
import { FileEditor } from "./FileEditor";
import { LoadingSpinner } from "./LoadingSpinner"; import { LoadingSpinner } from "./LoadingSpinner";
import { ArtifactsGallery } from "./ArtifactsGallery"; import { ArtifactsGallery } from "./ArtifactsGallery";
import { ViewHeader } from "./ViewHeader"; import { ViewHeader } from "./ViewHeader";
@@ -73,8 +74,25 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
const taskDocPlainPreviewRef = useRef<HTMLPreElement>(null); const taskDocPlainPreviewRef = useRef<HTMLPreElement>(null);
// Markdown render toggle for project file preview // Markdown render toggle for project file preview
const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false); 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()); 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 [selectionCommentOpen, setSelectionCommentOpen] = useState(false);
const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen }); const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen });
const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen }); const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen });
@@ -136,6 +154,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
setFileLoading(false); setFileLoading(false);
setRenderProjectMarkdown(false); setRenderProjectMarkdown(false);
setTaskDocMarkdownStates(new Map()); setTaskDocMarkdownStates(new Map());
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocSaving(false);
setEditingProjectFile(false);
setProjectFileDraft("");
setProjectFileSaving(false);
}, [projectId]); }, [projectId]);
const groupedDocuments = useMemo(() => { const groupedDocuments = useMemo(() => {
@@ -225,6 +249,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
setActiveTab(tab); setActiveTab(tab);
if (tab !== "tasks") { if (tab !== "tasks") {
setSelectedTaskDocumentId(null); setSelectedTaskDocumentId(null);
setEditingTaskDocumentId(null);
setTaskDocDraft("");
}
if (tab !== "project") {
setEditingProjectFile(false);
setProjectFileDraft("");
} }
}, []); }, []);
@@ -258,6 +288,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
setFileLoading(true); setFileLoading(true);
setFileError(null); setFileError(null);
setFileContent(null); setFileContent(null);
setEditingProjectFile(false);
setProjectFileDraft("");
const requestId = requestIdRef.current + 1; const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId; requestIdRef.current = requestId;
@@ -288,25 +320,85 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
setFileContent(null); setFileContent(null);
setFileError(null); setFileError(null);
setFileLoading(false); 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) => { const handleSelectTaskDocument = useCallback((docId: string) => {
setSelectedTaskDocumentId(docId); setSelectedTaskDocumentId(docId);
setEditingTaskDocumentId(null);
setTaskDocDraft("");
}, []); }, []);
const handleBackToTaskDocumentList = useCallback(() => { const handleBackToTaskDocumentList = useCallback(() => {
setSelectedTaskDocumentId(null); setSelectedTaskDocumentId(null);
setEditingTaskDocumentId(null);
setTaskDocDraft("");
}, []); }, []);
const handleToggleTaskDocMarkdown = useCallback((docId: string) => { const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
setTaskDocMarkdownStates((prev) => { setTaskDocMarkdownStates((prev) => {
const next = new Map(prev); const next = new Map(prev);
const current = next.get(docId) ?? false; const current = next.get(docId) ?? true;
next.set(docId, !current); next.set(docId, !current);
return next; 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 activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
const handleRetry = useCallback(async () => { const handleRetry = useCallback(async () => {
@@ -322,9 +414,10 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]); }, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? documents.length : artifacts.length; 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 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 <SelectionCommentPopover
selectedText={activeProjectSelection.selectedText} selectedText={activeProjectSelection.selectedText}
anchorRect={activeProjectSelection.anchorRect} anchorRect={activeProjectSelection.anchorRect}
@@ -333,7 +426,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
onOpenChange={setSelectionCommentOpen} onOpenChange={setSelectionCommentOpen}
/> />
) : null; ) : null;
const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? ( const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && !editingSelectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? (
<SelectionCommentPopover <SelectionCommentPopover
selectedText={activeTaskDocumentSelection.selectedText} selectedText={activeTaskDocumentSelection.selectedText}
anchorRect={activeTaskDocumentSelection.anchorRect} anchorRect={activeTaskDocumentSelection.anchorRect}
@@ -516,29 +609,56 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
<div className="documents-content-header"> <div className="documents-content-header">
<p className="documents-file-path-header">{selectedFile.path}</p> <p className="documents-file-path-header">{selectedFile.path}</p>
{/* {/*
FNXC:ArtifactsView 2026-07-10-16:10: FNXC:DocumentsView 2026-07-11-14:45:
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. 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 <div className="documents-task-document-actions">
className="documents-readonly-badge badge" {editingProjectFile ? (
title={t("documents.readOnlyHint", "This preview is read-only. Select text to comment and send it to a new task.")} <>
> <button className="btn btn-sm" onClick={handleCancelProjectFileEdit} disabled={projectFileSaving}>
{t("documents.readOnly", "Read-only")} {t("documents.cancelEdit", "Cancel")}
</span> </button>
<button <button className="btn btn-sm btn-primary" onClick={() => void handleSaveProjectFileEdit()} disabled={projectFileSaving}>
className="btn btn-sm document-mode-toggle" {projectFileSaving ? t("documents.saving", "Saving…") : t("documents.saveProjectFile", "Save")}
onClick={() => setRenderProjectMarkdown((prev) => !prev)} </button>
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")} <>
> <button
{renderProjectMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")} className="btn btn-sm document-mode-toggle"
</button> 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> </div>
{fileLoading ? ( {fileLoading ? (
<p className="documents-content-state"><LoadingSpinner label={t("documents.loadingFileContent", "Loading file content…")} /></p> <p className="documents-content-state"><LoadingSpinner label={t("documents.loadingFileContent", "Loading file content…")} /></p>
) : fileError ? ( ) : fileError ? (
<p className="documents-content-state documents-content-state--error">{fileError}</p> <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 ? ( ) : renderProjectMarkdown ? (
<div ref={markdownPreviewRef} className="documents-content-markdown"> <div ref={markdownPreviewRef} className="documents-content-markdown">
<div className="markdown-body"> <div className="markdown-body">
@@ -690,28 +810,62 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
</div> </div>
) : ( ) : (
<div className="documents-content-viewer documents-task-document-viewer"> <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-content-header documents-task-document-header">
<div className="documents-task-document-title-block"> <p className="documents-file-path-header">{selectedTaskDocument.taskId} / {selectedTaskDocument.key}</p>
<p className="documents-file-path-header">{selectedTaskDocument.taskId} / {selectedTaskDocument.key}</p> {/*
<div className="document-card-meta documents-task-document-meta"> FNXC:DocumentsView 2026-07-11-13:40:
<span className="document-card-author">{selectedTaskDocument.author}</span> 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.
<span className="document-card-separator">·</span> */}
<span>{t("documents.revisionShort", "v{{revision}}", { revision: selectedTaskDocument.revision })}</span> <div className="documents-task-document-actions">
<span className="document-card-separator">·</span> {editingSelectedTaskDocument ? (
<span className="document-card-date">{formatTimestamp(selectedTaskDocument.updatedAt)}</span> <>
</div> <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> </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> </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 ref={taskDocMarkdownPreviewRef} className="documents-content-markdown">
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedTaskDocument.content}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedTaskDocument.content}</ReactMarkdown>

View File

@@ -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 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 covers all popover surfaces: DocumentsView project-file preview (plain and markdown) and
FileEditor (editor and preview), desktop and mobile. 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); 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 — 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 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. 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); transform: translate(-50%, calc(-1 * var(--space-2xl))) scale(0.97);
} }

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core"; import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import { DocumentsView } from "../DocumentsView"; 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 { useArtifacts } from "../../hooks/useArtifacts";
import { useDocuments } from "../../hooks/useDocuments"; import { useDocuments } from "../../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles"; import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
@@ -15,6 +15,8 @@ vi.mock("../../api", () => ({
fetchArtifacts: vi.fn(), fetchArtifacts: vi.fn(),
fetchArtifact: vi.fn(), fetchArtifact: vi.fn(),
updateArtifact: vi.fn(), updateArtifact: vi.fn(),
putTaskDocument: vi.fn(),
saveWorkspaceFileContent: vi.fn(),
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`), artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
})); }));
@@ -47,6 +49,8 @@ const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail); const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
const mockFetchArtifact = vi.mocked(fetchArtifact); const mockFetchArtifact = vi.mocked(fetchArtifact);
const mockUpdateArtifact = vi.mocked(updateArtifact); const mockUpdateArtifact = vi.mocked(updateArtifact);
const mockPutTaskDocument = vi.mocked(putTaskDocument);
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
function mockSelectionRect() { function mockSelectionRect() {
const rect = new DOMRect(10, 20, 80, 12); const rect = new DOMRect(10, 20, 80, 12);
@@ -775,23 +779,48 @@ describe("DocumentsView", () => {
}); });
/* /*
FNXC:ArtifactsView 2026-07-10-16:20: FNXC:DocumentsView 2026-07-11-14:45:
First-run review: the preview pane did not communicate that it is view-only. The Read-only 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.
badge must render with the preview header in BOTH render modes (plain and markdown) — the
header markup is shared between desktop and mobile layouts.
*/ */
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} />); render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
// Landing tab is now Artifacts; these tests exercise the Project Files tab explicitly. // 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("tab", { name: /show project markdown files/i }));
fireEvent.click(screen.getByRole("button", { name: "Open README.md" })); fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
await screen.findByText(/Hello docs/); 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 })); fireEvent.click(screen.getByRole("button", { name: /edit project file/i }));
await screen.findByText("Hello docs"); const editor = screen.getByLabelText("file editor");
expect(screen.getByText("Read-only")).toBeInTheDocument(); 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 () => { 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("tab", { name: /show task documents/i }));
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" })); 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"); const plainPreview = screen.getByText("Alpha document content");
selectNodeText(plainPreview); selectNodeText(plainPreview);
@@ -866,7 +897,7 @@ describe("DocumentsView", () => {
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i })); 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: "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"); const markdownPreviewText = await screen.findByText("Alpha document content");
selectNodeText(markdownPreviewText); selectNodeText(markdownPreviewText);
@@ -928,6 +959,53 @@ describe("DocumentsView", () => {
expect(await screen.findByRole("button", { name: /add a comment/i })).toBeInTheDocument(); 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 () => { it("search filters task documents and clears filtered-out selection", async () => {
mockUseProjectMarkdownFiles.mockReturnValue({ mockUseProjectMarkdownFiles.mockReturnValue({
files: [], files: [],
@@ -1192,15 +1270,15 @@ describe("DocumentsView", () => {
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" })); fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
// Task document toggle should default to raw (not influenced by project toggle) // Task document toggle defaults to markdown (its own default, not influenced by project toggle)
const taskToggle = screen.getByRole("button", { name: /switch to markdown/i }); const taskToggle = screen.getByRole("button", { name: /switch to plain text/i });
expect(taskToggle).toHaveAttribute("aria-pressed", "false"); expect(taskToggle).toHaveAttribute("aria-pressed", "true");
// Toggle task document // Toggle task document to plain
fireEvent.click(taskToggle); 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 })); 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"); 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" })); 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(); 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 // Click to toggle to plain mode and back
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
// Click to toggle to markdown mode
fireEvent.click(toggleBtn); 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"); expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
}); });
}); });

View File

@@ -77,6 +77,12 @@ describe("SelectionCommentPopover", () => {
must include the `translate(-50%` positioning component. The popover is shared by must include the `translate(-50%` positioning component. The popover is shared by
DocumentsView (plain + markdown preview) and FileEditor (editor + preview), so this one DocumentsView (plain + markdown preview) and FileEditor (editor + preview), so this one
stylesheet invariant covers all surfaces. 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", () => { it("keeps the positioning translate in every trigger transform, including :active press state", () => {
const css = readFileSync(join(__dirname, "..", "SelectionCommentPopover.css"), "utf8"); const css = readFileSync(join(__dirname, "..", "SelectionCommentPopover.css"), "utf8");
@@ -87,7 +93,7 @@ describe("SelectionCommentPopover", () => {
const rulePattern = /([^{}]+)\{([^{}]*)\}/g; const rulePattern = /([^{}]+)\{([^{}]*)\}/g;
for (const match of uncommented.matchAll(rulePattern)) { for (const match of uncommented.matchAll(rulePattern)) {
const selector = match[1].trim(); 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] }); triggerBlocks.push({ selector, block: match[2] });
} }
} }
@@ -102,6 +108,13 @@ describe("SelectionCommentPopover", () => {
const activeBlocks = transformBlocks.filter(({ selector }) => selector.includes(":active")); 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); 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);
}
}); });
/* /*