diff --git a/.changeset/artifacts-doc-editing-and-comment-fix.md b/.changeset/artifacts-doc-editing-and-comment-fix.md new file mode 100644 index 0000000000..17718b1a83 --- /dev/null +++ b/.changeset/artifacts-doc-editing-and-comment-fix.md @@ -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. diff --git a/packages/dashboard/app/components/DocumentsView.css b/packages/dashboard/app/components/DocumentsView.css index ff30ed7d1e..10dcb076a3 100644 --- a/packages/dashboard/app/components/DocumentsView.css +++ b/packages/dashboard/app/components/DocumentsView.css @@ -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 { diff --git a/packages/dashboard/app/components/DocumentsView.tsx b/packages/dashboard/app/components/DocumentsView.tsx index 8c534ffb77..da390d961f 100644 --- a/packages/dashboard/app/components/DocumentsView.tsx +++ b/packages/dashboard/app/components/DocumentsView.tsx @@ -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(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>(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(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 ? ( ) : null; - const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? ( + const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && !editingSelectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? (

{selectedFile.path}

{/* - 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. */} - - {t("documents.readOnly", "Read-only")} - - +
+ {editingProjectFile ? ( + <> + + + + ) : ( + <> + + + + )} +
{fileLoading ? (

) : fileError ? (

{fileError}

+ ) : editingProjectFile ? ( +
+ +
) : renderProjectMarkdown ? (
@@ -690,28 +810,62 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
) : (
+ {/* + 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. + */}
-
-

{selectedTaskDocument.taskId} / {selectedTaskDocument.key}

-
- {selectedTaskDocument.author} - · - {t("documents.revisionShort", "v{{revision}}", { revision: selectedTaskDocument.revision })} - · - {formatTimestamp(selectedTaskDocument.updatedAt)} -
+

{selectedTaskDocument.taskId} / {selectedTaskDocument.key}

+ {/* + 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. + */} +
+ {editingSelectedTaskDocument ? ( + <> + + + + ) : ( + <> + + + + )}
-
- {selectedTaskDocumentRendersMarkdown ? ( +
+ {selectedTaskDocument.author} + · + {t("documents.revisionShort", "v{{revision}}", { revision: selectedTaskDocument.revision })} + · + {formatTimestamp(selectedTaskDocument.updatedAt)} +
+ {editingSelectedTaskDocument ? ( +
+ +
+ ) : selectedTaskDocumentRendersMarkdown ? (
{selectedTaskDocument.content} diff --git a/packages/dashboard/app/components/SelectionCommentPopover.css b/packages/dashboard/app/components/SelectionCommentPopover.css index 1dd90d52ac..d1f1938fa9 100644 --- a/packages/dashboard/app/components/SelectionCommentPopover.css +++ b/packages/dashboard/app/components/SelectionCommentPopover.css @@ -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); } diff --git a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx index bb2e0f64f7..ec1d20ea1b 100644 --- a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx @@ -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>); render(); // 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(); + 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(); + + 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(); + + 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"); }); }); diff --git a/packages/dashboard/app/components/__tests__/SelectionCommentPopover.test.tsx b/packages/dashboard/app/components/__tests__/SelectionCommentPopover.test.tsx index 9945cfbfab..2680aab08d 100644 --- a/packages/dashboard/app/components/__tests__/SelectionCommentPopover.test.tsx +++ b/packages/dashboard/app/components/__tests__/SelectionCommentPopover.test.tsx @@ -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); + } }); /*