From 4fb360631c26664f4f06b2c13a24b60143c69d5a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 20:50:59 -0700 Subject: [PATCH] FN-7833: render task Artifacts documents as Markdown, expanded by default Renders every task document in the Artifacts tab expanded and as rendered Markdown by default, with the Markdown/Plain preference persisted across sessions instead of resetting per document. - Replace single expandedDocKey/expandedContent state with a multi-key expandedDocKeys Set plus a per-document revisionContentByKey map so multiple documents can be expanded simultaneously. - Default renderMarkdown to true and persist the operator's Markdown/Plain toggle choice to localStorage (fusion.taskDocuments.renderMarkdown) via readBooleanPref/writeBooleanPref helpers. - Auto-expand newly loaded documents while preserving collapse state for documents the user has explicitly collapsed, tracked per taskId. - Update handleStartEdit and handleViewRevision to operate per-document-key instead of a single global expanded document. - Add changeset (@runfusion/fusion patch) describing the Artifacts-tab default-expanded Markdown behavior. - Rework TaskDocumentsTab tests for multi-document expand/collapse, per-card markdown rendering, and localStorage-backed preference persistence. Files changed: .changeset/fn-7833-artifacts-tab-markdown-expand-default.md | 7 + packages/dashboard/app/components/TaskDocumentsTab.tsx | 149 +++++++++----- packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx | 223 +++++---------------- 3 files changed, 160 insertions(+), 219 deletions(-) Fusion-Task-Id: FN-7833 Fusion-Task-Lineage: 1915c594-7191-47a2-962b-18136d572cf1 Co-authored-by: Fusion (runfusion.ai) --- ...3-artifacts-tab-markdown-expand-default.md | 7 + .../app/components/TaskDocumentsTab.tsx | 149 ++++++++---- .../__tests__/TaskDocumentsTab.test.tsx | 223 ++++-------------- 3 files changed, 160 insertions(+), 219 deletions(-) create mode 100644 .changeset/fn-7833-artifacts-tab-markdown-expand-default.md diff --git a/.changeset/fn-7833-artifacts-tab-markdown-expand-default.md b/.changeset/fn-7833-artifacts-tab-markdown-expand-default.md new file mode 100644 index 0000000000..f4d63de32b --- /dev/null +++ b/.changeset/fn-7833-artifacts-tab-markdown-expand-default.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show task Artifacts-tab documents expanded with Markdown by default. +category: feature +dev: TaskDocumentsTab now uses multi-expand document state and persists the Markdown/Plain preference. diff --git a/packages/dashboard/app/components/TaskDocumentsTab.tsx b/packages/dashboard/app/components/TaskDocumentsTab.tsx index 77bfd56fbb..25966c2368 100644 --- a/packages/dashboard/app/components/TaskDocumentsTab.tsx +++ b/packages/dashboard/app/components/TaskDocumentsTab.tsx @@ -22,6 +22,27 @@ import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia"; // Document key validation: alphanumeric, hyphens, underscores, 1-64 chars const DOCUMENT_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/; const MAX_CONTENT_PREVIEW = 200; +const TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY = "fusion.taskDocuments.renderMarkdown"; + +function readBooleanPref(key: string, defaultValue: boolean): boolean { + if (typeof window === "undefined") return defaultValue; + try { + const raw = window.localStorage.getItem(key); + if (raw === null) return defaultValue; + return raw === "true"; + } catch { + return defaultValue; + } +} + +function writeBooleanPref(key: string, value: boolean): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, value ? "true" : "false"); + } catch { + // ignore storage failures (quota, private mode, etc.) + } +} interface TaskDocumentsTabProps { taskId: string; @@ -109,8 +130,8 @@ export function TaskDocumentsTab({ const { t } = useTranslation("app"); const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); - const [expandedDocKey, setExpandedDocKey] = useState(null); - const [expandedContent, setExpandedContent] = useState(""); + const [expandedDocKeys, setExpandedDocKeys] = useState>(() => new Set()); + const [revisionContentByKey, setRevisionContentByKey] = useState>({}); const [editingDocKey, setEditingDocKey] = useState(null); const [editContent, setEditContent] = useState(""); const [showHistory, setShowHistory] = useState(null); @@ -122,7 +143,11 @@ export function TaskDocumentsTab({ const [saving, setSaving] = useState(false); const [deletingKey, setDeletingKey] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); - const [renderMarkdown, setRenderMarkdown] = useState(false); + /* + * FNXC:ArtifactRegistry 2026-07-11-00:00: + * FN-7833 makes task Artifacts-tab documents readable without extra clicks: render Markdown by default and persist the operator's Markdown/Plain preference for future task document views. + */ + const [renderMarkdown, setRenderMarkdown] = useState(() => readBooleanPref(TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY, true)); /* * FNXC:ArtifactRegistry 2026-06-29-00:00: * Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls. @@ -131,12 +156,28 @@ export function TaskDocumentsTab({ const lightboxDialogRef = useRef(null); const lightboxCloseRef = useRef(null); const lightboxReturnFocusRef = useRef(null); + const loadedTaskIdRef = useRef(taskId); + const documentKeysRef = useRef>(new Set()); const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId }); const loadDocuments = useCallback(async () => { try { const docs = await fetchTaskDocuments(taskId, projectId); + const previousKeys = loadedTaskIdRef.current === taskId ? documentKeysRef.current : new Set(); + const nextKeys = new Set(docs.map((doc) => doc.key)); + loadedTaskIdRef.current = taskId; + documentKeysRef.current = nextKeys; setDocuments(docs); + setExpandedDocKeys((current) => { + const next = new Set(); + for (const doc of docs) { + if (current.has(doc.key) || !previousKeys.has(doc.key)) { + next.add(doc.key); + } + } + return next; + }); + setRevisionContentByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => nextKeys.has(key)))); } catch (error) { addToast(getErrorMessage(error) || t("taskDocuments.failedToLoad", "Failed to load documents"), "error"); } finally { @@ -154,23 +195,30 @@ export function TaskDocumentsTab({ } }, [addToast, artifactsError, t]); - async function handleExpandDocument(doc: TaskDocument) { - if (expandedDocKey === doc.key) { - setExpandedDocKey(null); - setExpandedContent(""); - setEditingDocKey(null); - setEditContent(""); - setShowHistory(null); - setRevisions([]); - setRenderMarkdown(false); - } else { - setExpandedDocKey(doc.key); - setExpandedContent(doc.content); - setEditingDocKey(null); - setEditContent(""); - setShowHistory(null); - setRevisions([]); - setRenderMarkdown(false); + useEffect(() => { + writeBooleanPref(TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown); + }, [renderMarkdown]); + + function handleExpandDocument(doc: TaskDocument) { + const isExpanded = expandedDocKeys.has(doc.key); + setExpandedDocKeys((current) => { + const next = new Set(current); + if (isExpanded) { + next.delete(doc.key); + } else { + next.add(doc.key); + } + return next; + }); + if (isExpanded) { + if (editingDocKey === doc.key) { + setEditingDocKey(null); + setEditContent(""); + } + if (showHistory === doc.key) { + setShowHistory(null); + setRevisions([]); + } } } @@ -192,11 +240,9 @@ export function TaskDocumentsTab({ } } - function handleStartEdit() { - if (expandedDocKey) { - setEditingDocKey(expandedDocKey); - setEditContent(expandedContent); - } + function handleStartEdit(doc: TaskDocument) { + setEditingDocKey(doc.key); + setEditContent(revisionContentByKey[doc.key] ?? doc.content); } function handleCancelEdit() { @@ -211,12 +257,12 @@ export function TaskDocumentsTab({ await putTaskDocument(taskId, editingDocKey, editContent, {}, projectId); setEditingDocKey(null); setEditContent(""); + setRevisionContentByKey((current) => { + const next = { ...current }; + delete next[editingDocKey]; + return next; + }); await loadDocuments(); - // Refresh expanded content - const updated = documents.find((d) => d.key === editingDocKey); - if (updated) { - setExpandedContent(updated.content); - } addToast(t("taskDocuments.saved", "Document saved"), "success"); } catch (error) { addToast(getErrorMessage(error) || t("taskDocuments.failedToSave", "Failed to save document"), "error"); @@ -264,10 +310,16 @@ export function TaskDocumentsTab({ await deleteTaskDocument(taskId, key, projectId); setConfirmDelete(null); setDeletingKey(null); - if (expandedDocKey === key) { - setExpandedDocKey(null); - setExpandedContent(""); - } + setExpandedDocKeys((current) => { + const next = new Set(current); + next.delete(key); + return next; + }); + setRevisionContentByKey((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); if (showHistory === key) { setShowHistory(null); setRevisions([]); @@ -281,8 +333,8 @@ export function TaskDocumentsTab({ } } - function handleViewRevision(revision: TaskDocumentRevision) { - setExpandedContent(revision.content); + function handleViewRevision(docKey: string, revision: TaskDocumentRevision) { + setRevisionContentByKey((current) => ({ ...current, [docKey]: revision.content })); setEditingDocKey(null); setEditContent(""); } @@ -468,7 +520,11 @@ export function TaskDocumentsTab({ ) ) : (
- {documents.map((doc) => ( + {documents.map((doc) => { + const isExpanded = expandedDocKeys.has(doc.key); + const displayedContent = revisionContentByKey[doc.key] ?? doc.content; + + return (
@@ -483,7 +539,7 @@ export function TaskDocumentsTab({
{/* Expanded Content View */} - {expandedDocKey === doc.key && editingDocKey !== doc.key && ( + {isExpanded && editingDocKey !== doc.key && ( <>
) : ( -
{expandedContent}
+
{displayedContent}
)}
@@ -525,7 +581,7 @@ export function TaskDocumentsTab({
handleViewRevision(revision)} + onClick={() => handleViewRevision(doc.key, revision)} >
v{revision.revision} @@ -570,9 +626,9 @@ export function TaskDocumentsTab({
- {expandedDocKey === doc.key && ( + {isExpanded && ( <> )} @@ -630,7 +686,8 @@ export function TaskDocumentsTab({ )}
- ))} + ); + })}
)} diff --git a/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx index f20b1c6dcc..3a5971829a 100644 --- a/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx @@ -1,5 +1,9 @@ +/* +FNXC:DashboardTests 2026-07-11-00:00: +FN-7833 changed per-task Artifacts-tab document defaults. Keep multi-document coverage here so the component cannot regress to single-expanded or Plain-first rendering while the separate Documents view remains out of scope. +*/ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import type { ArtifactWithTask, TaskDocument } from "@fusion/core"; import { TaskDocumentsTab } from "../TaskDocumentsTab"; import { artifactMediaUrl, fetchTaskDocuments, fetchTaskDocumentRevisions } from "../../api"; @@ -23,6 +27,12 @@ const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions); const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl); const mockUseArtifacts = vi.mocked(useArtifacts); +function getDocumentCard(key: string): HTMLElement { + const keyElement = screen.getAllByText(key).find((element) => element.classList.contains("task-document-key")); + expect(keyElement).toBeDefined(); + return keyElement!.closest(".task-document-card") as HTMLElement; +} + const mockArtifacts: ArtifactWithTask[] = [ { id: "artifact-image", @@ -97,6 +107,7 @@ describe("TaskDocumentsTab", () => { beforeEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); mockFetchTaskDocuments.mockResolvedValue(mockDocuments); mockFetchTaskDocumentRevisions.mockResolvedValue([]); mockArtifactMediaUrl.mockImplementation((id: string) => `/api/artifacts/${id}/media`); @@ -113,7 +124,7 @@ describe("TaskDocumentsTab", () => { await waitFor(() => { expect(screen.getByRole("heading", { name: "Artifacts" })).toBeInTheDocument(); - expect(screen.getByText("plan")).toBeInTheDocument(); + expect(screen.getAllByText("plan").length).toBeGreaterThan(0); }); expect(screen.getByText("notes")).toBeInTheDocument(); @@ -148,7 +159,7 @@ describe("TaskDocumentsTab", () => { render(); await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); + expect(screen.getAllByText("plan").length).toBeGreaterThan(0); }); expect(screen.queryByRole("heading", { name: "Media artifacts" })).not.toBeInTheDocument(); @@ -186,7 +197,7 @@ describe("TaskDocumentsTab", () => { render(); await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); + expect(screen.getAllByText("plan").length).toBeGreaterThan(0); expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument(); }); @@ -272,7 +283,7 @@ describe("TaskDocumentsTab", () => { fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); expect(closeButton).toHaveFocus(); - screen.getAllByRole("button", { name: "Expand" })[0].focus(); + screen.getAllByRole("button", { name: "Collapse" })[0].focus(); fireEvent.keyDown(document, { key: "Tab" }); expect(closeButton).toHaveFocus(); }); @@ -292,212 +303,78 @@ describe("TaskDocumentsTab", () => { }); }); - it("expands document to show content", async () => { + it("renders every task document expanded as markdown by default", async () => { render(); - await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); - }); - - const expandButton = screen.getAllByRole("button", { name: /expand/i })[0]; - fireEvent.click(expandButton); - - await waitFor(() => { - expect(screen.getByText(/This is the \*\*plan\*\* content/)).toBeInTheDocument(); - }); + const planStrong = (await screen.findAllByText("plan")).find((element) => element.tagName === "STRONG"); + expect(planStrong).toBeDefined(); + expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument(); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + expect(document.querySelectorAll(".task-document-content-markdown .markdown-body")).toHaveLength(2); + expect(document.querySelector("pre.task-document-content-text")).not.toBeInTheDocument(); }); - it("collapses document when expand button clicked again", async () => { + it("collapses only the selected document while other documents stay expanded", async () => { render(); - await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); - }); + await screen.findByText("Item 1"); + const planCard = getDocumentCard("plan"); - const expandButton = screen.getAllByRole("button", { name: /expand/i })[0]; - fireEvent.click(expandButton); + fireEvent.click(within(planCard).getByRole("button", { name: /collapse/i })); await waitFor(() => { - expect(screen.getByText(/This is the \*\*plan\*\* content/)).toBeInTheDocument(); + expect(screen.queryByText("This is the")).not.toBeInTheDocument(); }); - - fireEvent.click(screen.getByRole("button", { name: /collapse/i })); - - // Content should be hidden - expect(screen.queryByText(/This is the/)).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument(); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(within(planCard).getByRole("button", { name: /expand/i })).toBeInTheDocument(); }); describe("markdown toggle", () => { - it("defaults to raw text mode", async () => { + it("defaults to markdown render mode", async () => { render(); await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); - }); - - const expandButton = screen.getAllByRole("button", { name: /expand/i })[0]; - fireEvent.click(expandButton); - - await waitFor(() => { - // Should show raw markdown syntax - expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument(); + expect(document.querySelector(".task-document-content-markdown strong")?.textContent).toBe("plan"); }); + expect(screen.getAllByRole("button", { name: /switch to plain text/i })[0]).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByText(/\*\*plan\*\*/)).not.toBeInTheDocument(); }); - it("toggles to markdown render mode", async () => { + it("toggles to raw text mode", async () => { render(); - await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); - }); - - const expandButton = screen.getAllByRole("button", { name: /expand/i })[0]; - fireEvent.click(expandButton); - - await waitFor(() => { - expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument(); - }); - - // Find and click the markdown toggle - const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i }); - expect(toggleBtn).toHaveAttribute("aria-pressed", "false"); - + const toggleBtn = (await screen.findAllByRole("button", { name: /switch to plain text/i }))[0]; fireEvent.click(toggleBtn); - // Should now be in markdown mode - expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true"); - // Bold text should be rendered as - use a data-testid to scope the query - const container = document.querySelector(".task-document-content-markdown"); - expect(container).not.toBeNull(); - const strongEl = container!.querySelector("strong"); - expect(strongEl).not.toBeNull(); - expect(strongEl!.textContent).toBe("plan"); - }); - - it("toggles back to raw text mode", async () => { - render(); - await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); + expect(document.querySelectorAll("pre.task-document-content-text")).toHaveLength(2); }); - - const expandButton = screen.getAllByRole("button", { name: /expand/i })[0]; - fireEvent.click(expandButton); - - await waitFor(() => { - expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument(); - }); - - // Toggle to markdown mode - fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i })); - - await waitFor(() => { - expect(document.querySelector(".task-document-content-markdown strong")).not.toBeNull(); - }); - - // Toggle back to raw text - fireEvent.click(screen.getByRole("button", { name: /switch to plain text/i })); - - // Should be back to raw text mode expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument(); + expect(screen.getByText(/# Notes/)).toBeInTheDocument(); + expect(document.querySelector(".task-document-content-markdown")).not.toBeInTheDocument(); }); - it("resets markdown mode when switching documents", async () => { + it("persists raw text preference across collapse and re-expand", async () => { render(); - await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); - }); + fireEvent.click((await screen.findAllByRole("button", { name: /switch to plain text/i }))[0]); + await waitFor(() => expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument()); - // Expand first document and enable markdown mode - const expandButtons = screen.getAllByRole("button", { name: /expand/i }); - fireEvent.click(expandButtons[0]); + const planCard = getDocumentCard("plan"); + fireEvent.click(within(planCard).getByRole("button", { name: /collapse/i })); + fireEvent.click(within(planCard).getByRole("button", { name: /expand/i })); - await waitFor(() => { - expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i })); - - await waitFor(() => { - expect(document.querySelector(".task-document-content-markdown strong")).not.toBeNull(); - }); - - // Collapse first document - fireEvent.click(screen.getByRole("button", { name: /collapse/i })); - - // Expand second document - fireEvent.click(expandButtons[1]); - - await waitFor(() => { - // Should be in raw text mode by default - expect(screen.getByText(/# Notes/)).toBeInTheDocument(); - }); - - // Toggle button should be in raw text mode - const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i }); - expect(toggleBtn).toHaveAttribute("aria-pressed", "false"); + expect(await screen.findByText(/\*\*plan\*\*/)).toBeInTheDocument(); + expect(within(planCard).getByRole("button", { name: /switch to markdown/i })).toHaveAttribute("aria-pressed", "false"); }); - it("resets markdown mode when collapsing document", async () => { + it("renders markdown list syntax without an extra toggle click", async () => { render(); - await waitFor(() => { - expect(screen.getByText("plan")).toBeInTheDocument(); - }); - - // Expand document and enable markdown mode - const expandButton = screen.getAllByRole("button", { name: /expand/i })[0]; - fireEvent.click(expandButton); - - await waitFor(() => { - expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i })); - - await waitFor(() => { - expect(document.querySelector(".task-document-content-markdown strong")).not.toBeNull(); - }); - - // Collapse document - fireEvent.click(screen.getByRole("button", { name: /collapse/i })); - - // Re-expand document (first expand button after collapse) - fireEvent.click(screen.getAllByRole("button", { name: /expand/i })[0]); - - await waitFor(() => { - // Should be in raw text mode by default - expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument(); - }); - - const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i }); - expect(toggleBtn).toHaveAttribute("aria-pressed", "false"); - }); - - it("renders markdown list syntax", async () => { - render(); - - await waitFor(() => { - expect(screen.getByText("notes")).toBeInTheDocument(); - }); - - // Expand the notes document (second one) - const expandButtons = screen.getAllByRole("button", { name: /expand/i }); - fireEvent.click(expandButtons[1]); - - await waitFor(() => { - expect(screen.getByText(/# Notes/)).toBeInTheDocument(); - }); - - // Toggle to markdown mode - fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i })); - - // Check heading is rendered const heading = await screen.findByRole("heading", { name: "Notes" }); expect(heading).toBeInTheDocument(); - - // Check list items are rendered expect(screen.getByText("Item 1")).toBeInTheDocument(); expect(screen.getByText("Item 2")).toBeInTheDocument(); });