FN-7812: extend select-to-comment popover to Task Documents right pane

Adds select-to-comment parity to the Task Documents right pane in the Artifacts view, reusing the existing Project Files selection-comment pattern (useSelectionComment/SelectionCommentPopover) so operators can highlight task-document content and send it to a new task.

- Add markdown/plain preview refs and useSelectionComment hooks scoped to the selected Task Document, following the existing Plain/Markdown render toggle
- Gate the Task Documents selection popover on activeTab === "tasks" and the selected task document (separate from the Project Files popover, which stays gated on activeTab === "project") so tab switches never cross-render popovers
- Compose the New Task description source as `taskId/key` for task-document selections, mirroring the file-path convention used for Project Files
- Add regression tests covering plain/markdown task-document selection, empty-pane gating, tab isolation between Task Documents and Project Files popovers, and the mobile detail pane
- Update dashboard-guide.md to document select-to-comment support for Task Documents alongside Project Files
- Add a minor changeset for @runfusion/fusion documenting the feature (depends on FN-7811)

Files changed:
 .changeset/fn-7812-task-documents-select-to-comment.md            |  7 ++
 docs/dashboard-guide.md                                           |  4 +-
 packages/dashboard/app/components/DocumentsView.tsx                | 37 ++++++---
 packages/dashboard/app/components/__tests__/DocumentsView.test.tsx | 93 ++++++++++++++++++++++
 4 files changed, 130 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7812

Fusion-Task-Lineage: ace10d70-35fd-42fe-8a4a-e2c2c7c7c27b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 23:25:02 -07:00
parent 595d323ce6
commit 56b20a76af
4 changed files with 130 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Artifacts view — select text in a Task Document's content pane to comment and send it to a new task.
category: feature
dev: DocumentsView Task Documents right pane reuses the Project Files `useSelectionComment`/`SelectionCommentPopover` pattern (markdown + plain refs following the render toggle, composer-open lock, popover gated on the task-document selection + `onSendSelectionToTask`). Project Files behavior and the markdown/plain toggle are unchanged. Depends on FN-7811.

View File

@@ -836,7 +836,7 @@ Features:
- Empty states: with no search query it shows `No artifacts yet.` plus the hint that artifacts are created by agents, users, and system tools; with a search query it shows `No artifacts match "<query>".` - Empty states: with no search query it shows `No artifacts yet.` plus the hint that artifacts are created by agents, users, and system tools; with a search query it shows `No artifacts match "<query>".`
- Error state: a failed artifact list request uses the shared `Failed to load artifacts: <error>` panel with a **Retry** action that re-runs the artifact fetch - Error state: a failed artifact list request uses the shared `Failed to load artifacts: <error>` panel with a **Retry** action that re-runs the artifact fetch
- Toggle between raw text and rendered markdown using the **Markdown/Plain** button - Toggle between raw text and rendered markdown using the **Markdown/Plain** button
- Highlight text in raw or rendered project-file previews, choose **Add comment**, and send the file path, selected snippet, and your comment to the **New Task** dialog - Highlight text in raw or rendered project-file previews or the selected Task Document's right pane, choose **Add comment**, and send the source path/key, selected snippet, and your comment to the **New Task** dialog
Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery. Artifact list live-refresh does not depend on that best-effort message; it listens to the registry registration event. Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery. Artifact list live-refresh does not depend on that best-effort message; it listens to the registry registration event.
@@ -871,7 +871,7 @@ Artifacts view supports toggling between raw text and formatted markdown when vi
The toggle button is accessible with `aria-pressed` for screen readers. Toggle state is scoped per-document, so switching between documents resets the view to raw mode. The toggle button is accessible with `aria-pressed` for screen readers. Toggle state is scoped per-document, so switching between documents resets the view to raw mode.
Project-file previews also support selection comments in both raw and rendered markdown modes. Select text, click **Add comment**, enter a short note, and Fusion opens **New Task** with a seeded description containing the file path, snippet, and comment. Project-file previews and selected Task Documents also support selection comments in both raw and rendered markdown modes. Select text, click **Add comment**, enter a short note, and Fusion opens **New Task** with a seeded description containing the file path or task-document key, snippet, and comment.
## Todo View ## Todo View

View File

@@ -74,6 +74,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
const markdownPreviewRef = useRef<HTMLDivElement>(null); const markdownPreviewRef = useRef<HTMLDivElement>(null);
const plainPreviewRef = useRef<HTMLPreElement>(null); const plainPreviewRef = useRef<HTMLPreElement>(null);
const taskDocMarkdownPreviewRef = useRef<HTMLDivElement>(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) // Markdown render toggles per task document card (scoped by doc ID)
@@ -81,6 +83,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
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 });
const taskDocMarkdownSelection = useSelectionComment(taskDocMarkdownPreviewRef, { locked: selectionCommentOpen });
const taskDocPlainSelection = useSelectionComment(taskDocPlainPreviewRef, { locked: selectionCommentOpen });
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection; const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : ""; const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
@@ -168,7 +172,10 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
/* /*
FNXC:DocumentsView 2026-07-10-17:30: FNXC:DocumentsView 2026-07-10-17:30:
Task Documents now mirrors the Project Files sidebar/right-pane contract: the task-document selection is deliberately separate from selectedFile so tab switching cannot leak project file content into the Task Documents pane. Select-to-comment remains Project-Files-only for FN-7811 and is tracked as follow-up scope; Task Documents preserves only its existing Plain/Markdown render toggle. Task Documents now mirrors the Project Files sidebar/right-pane contract: the task-document selection is deliberately separate from selectedFile so tab switching cannot leak project file content into the Task Documents pane. Task Documents keeps its own Plain/Markdown render toggle so Project Files state never controls task-document rendering.
FNXC:DocumentsView 2026-07-10-23:41:
FN-7812 extends the existing select-to-comment affordance to the Task Documents right pane without a new comment model. The active task-document selection ref follows the same Plain/Markdown toggle, and the composed source path uses taskId/key so operators can identify the originating task document. Keep Task Documents gated by selectedTaskDocument and Project Files gated by selectedFile so tab switches cannot cross-render popovers; the shared composer-open lock is safe because only one tab pane is mounted at a time.
*/ */
const selectedTaskDocument = useMemo(() => { const selectedTaskDocument = useMemo(() => {
if (!selectedTaskDocumentId) { if (!selectedTaskDocumentId) {
@@ -320,7 +327,9 @@ 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 selectionPopover = selectedFile && onSendSelectionToTask && activeProjectSelection ? ( const selectedTaskDocumentRendersMarkdown = selectedTaskDocument ? (taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) : false;
const activeTaskDocumentSelection = selectedTaskDocumentRendersMarkdown ? taskDocMarkdownSelection : taskDocPlainSelection;
const selectionPopover = activeTab === "project" && selectedFile && onSendSelectionToTask && activeProjectSelection ? (
<SelectionCommentPopover <SelectionCommentPopover
selectedText={activeProjectSelection.selectedText} selectedText={activeProjectSelection.selectedText}
anchorRect={activeProjectSelection.anchorRect} anchorRect={activeProjectSelection.anchorRect}
@@ -329,6 +338,15 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
onOpenChange={setSelectionCommentOpen} onOpenChange={setSelectionCommentOpen}
/> />
) : null; ) : null;
const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? (
<SelectionCommentPopover
selectedText={activeTaskDocumentSelection.selectedText}
anchorRect={activeTaskDocumentSelection.anchorRect}
filePath={`${selectedTaskDocument.taskId}/${selectedTaskDocument.key}`}
onSubmit={onSendSelectionToTask}
onOpenChange={setSelectionCommentOpen}
/>
) : null;
const searchPlaceholder = activeTab === "project" const searchPlaceholder = activeTab === "project"
? t("documents.searchProjectFiles", "Search project markdown files…") ? t("documents.searchProjectFiles", "Search project markdown files…")
@@ -691,22 +709,23 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
<button <button
className="btn btn-sm document-mode-toggle" className="btn btn-sm document-mode-toggle"
onClick={() => handleToggleTaskDocMarkdown(selectedTaskDocument.id)} onClick={() => handleToggleTaskDocMarkdown(selectedTaskDocument.id)}
aria-label={(taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")} aria-label={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
aria-pressed={taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false} aria-pressed={selectedTaskDocumentRendersMarkdown}
title={(taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")} title={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
> >
{(taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")} {selectedTaskDocumentRendersMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
</button> </button>
</div> </div>
{(taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) ? ( {selectedTaskDocumentRendersMarkdown ? (
<div 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>
</div> </div>
</div> </div>
) : ( ) : (
<pre className="document-card-content-text documents-content-viewer-text">{selectedTaskDocument.content}</pre> <pre ref={taskDocPlainPreviewRef} className="document-card-content-text documents-content-viewer-text">{selectedTaskDocument.content}</pre>
)} )}
{taskDocumentSelectionPopover}
</div> </div>
)} )}
</section> </section>

View File

@@ -298,6 +298,7 @@ describe("DocumentsView", () => {
afterEach(() => { afterEach(() => {
window.innerWidth = originalInnerWidth; window.innerWidth = originalInnerWidth;
document.getSelection()?.removeAllRanges();
}); });
/* /*
@@ -834,6 +835,98 @@ describe("DocumentsView", () => {
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Review this rendered content.")); expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Review this rendered content."));
}); });
/*
FNXC:DocumentsView 2026-07-10-23:46:
FN-7812 adds select-to-comment parity to Task Documents, so tests must cover the same surface checklist as Project Files: plain and markdown render modes, desktop and mobile detail layouts, empty-pane gating, tab isolation, and task-document file context in the composed New Task description.
*/
it("sends selected plain task document text to a new task description", 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" }));
const plainPreview = screen.getByText("Alpha document content");
selectNodeText(plainPreview);
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Follow up from the task doc." } });
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: KB-001/plan"));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Alpha document content"));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Follow up from the task doc."));
});
it("sends selected markdown task document text to a new task description", 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" }));
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
const markdownPreviewText = await screen.findByText("Alpha document content");
selectNodeText(markdownPreviewText);
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Review rendered task doc." } });
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: KB-001/plan"));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Alpha document content"));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Review rendered task doc."));
});
it("does not show the task document comment trigger in the empty right pane", () => {
const onSendSelectionToTask = vi.fn();
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} onSendSelectionToTask={onSendSelectionToTask} />);
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
expect(screen.getByText("Select a task document to view its content.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add a comment/i })).not.toBeInTheDocument();
});
it("keeps task document and project file selection comment popovers isolated", 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("tab", { name: /show project markdown files/i }));
expect(screen.queryByRole("button", { name: /add a comment/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
const projectPreview = await screen.findByText(/Hello docs/);
selectNodeText(projectPreview);
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Project file still works." } });
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: README.md"));
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Project file still works."));
expect(onSendSelectionToTask).not.toHaveBeenCalledWith(expect.stringContaining("File: KB-001/plan"));
});
it("shows the task document comment trigger in the mobile detail pane", async () => {
window.innerWidth = 600;
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" }));
const mobilePreview = screen.getByText("Alpha document content");
selectNodeText(mobilePreview);
expect(await screen.findByRole("button", { name: /add a comment/i })).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: [],