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);
}
/*
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 {

View File

@@ -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>

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

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 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");
});
});

View File

@@ -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);
}
});
/*