feat(dashboard): editable task documents & project files in Artifacts view; fix Add-comment CSS-order regression
- Task Documents right pane gains in-place editing with the shared CodeMirror FileEditor (Save via PUT /tasks/:id/documents/:key); task documents now render markdown by default. - Project Files pane is editable the same way via the project workspace file API, replacing the Read-only badge contract. - Fix "Add comment" doing nothing again: `.selection-comment-trigger:active` tied the global `.btn:active` at (0,2,0) and lost to a bundle-order flip, teleporting the trigger mid-press so click never fired. `:active` rules now use `.btn.selection-comment-trigger` (0,3,0); regression test asserts the prefix so a plain-selector revert fails. - Align the task-document header: path box and Plain/Edit (Cancel/Save) actions share one row, meta line sits below. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/artifacts-doc-editing-and-comment-fix.md
Normal file
7
.changeset/artifacts-doc-editing-and-comment-fix.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Task documents and project files are now editable in the Artifacts view, task documents render markdown by default, and the select-to-comment "Add comment" button works again.
|
||||||
|
category: feature
|
||||||
|
dev: DocumentsView embeds the shared CodeMirror FileEditor for task-document (PUT /tasks/:id/documents/:key) and project-file (project workspace file API) edits. The Add comment no-op was a CSS bundle-order regression — `.btn:active` out-ordered the equal-specificity trigger rule; the `:active` rules now use `.btn.selection-comment-trigger` (0,3,0) with a test asserting the prefix.
|
||||||
@@ -305,6 +305,29 @@ Artifacts controls are the first page content below the shared header, so add a
|
|||||||
gap: var(--space-sm);
|
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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user