FN-7434: show task status badges in document groups

Show task completion metadata directly in Documents task group headers.

- Render task status badges for task document groups when column metadata is available.
- Preserve non-interactive status metadata in collapsed and mobile task group headers.
- Cover done, non-done, archived, custom, and legacy task document status cases.
- Document the Documents view status badge behavior and add a patch changeset.

Files changed:
 .changeset/fn-7434-document-task-status.md         |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/DocumentsView.css     |  16 ++-
 .../dashboard/app/components/DocumentsView.tsx     |  31 ++++-
 .../components/__tests__/DocumentsView.test.tsx    | 148 +++++++++++++++++++++
 5 files changed, 199 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7434

Fusion-Task-Lineage: b04161b3-c052-4eee-b9ed-5760eb9b7a4e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 12:00:58 -07:00
parent f33b701eee
commit b493a1e9e5
5 changed files with 199 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show task status badges on Documents task groups.
category: fix
dev: DocumentsView now renders taskColumn metadata in task document group headers and covers collapsed done/non-done states.

View File

@@ -674,7 +674,7 @@ Artifacts view aggregates project markdown files, task documents, and registered
Features: Features:
- Group task documents by task ID (with revision history metadata) - Group task documents by task ID (with revision history metadata) and show the parent task status badge in each task group header when status metadata is available
- Search documents across tasks - Search documents across tasks
- Open project markdown files with inline preview - Open project markdown files with inline preview
- Browse the **Artifacts** tab for registry media registered by any agent, dashboard chat/user action, or system tool across tasks - Browse the **Artifacts** tab for registry media registered by any agent, dashboard chat/user action, or system tool across tasks

View File

@@ -364,7 +364,7 @@ Artifacts controls are the first page content below the shared header, so add a
.documents-group-header { .documents-group-header {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
@@ -421,6 +421,14 @@ Artifacts controls are the first page content below the shared header, so add a
white-space: nowrap; white-space: nowrap;
} }
.documents-group-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
justify-self: end;
white-space: nowrap;
}
.documents-group-count { .documents-group-count {
font-size: 12px; font-size: 12px;
color: var(--text-dim); color: var(--text-dim);
@@ -903,6 +911,7 @@ The artifacts tab is a thumbnail-first responsive media gallery for agent-create
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: grid-template-areas:
"toggle count" "toggle count"
"status status"
"link link"; "link link";
row-gap: var(--space-xs); row-gap: var(--space-xs);
} }
@@ -911,6 +920,11 @@ The artifacts tab is a thumbnail-first responsive media gallery for agent-create
grid-area: toggle; grid-area: toggle;
} }
.documents-group-status {
grid-area: status;
justify-self: start;
}
.documents-group-count { .documents-group-count {
grid-area: count; grid-area: count;
justify-self: end; justify-self: end;

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react"; import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, 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 { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core"; import type { ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { artifactMediaUrl, fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api"; import { artifactMediaUrl, fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
import { useArtifacts } from "../hooks/useArtifacts"; import { useArtifacts } from "../hooks/useArtifacts";
@@ -15,6 +15,7 @@ import { SelectionCommentPopover } from "./SelectionCommentPopover";
import { LoadingSpinner } from "./LoadingSpinner"; import { LoadingSpinner } from "./LoadingSpinner";
import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia"; import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia";
import { ViewHeader } from "./ViewHeader"; import { ViewHeader } from "./ViewHeader";
import { useColumnLabel } from "../i18n/labels";
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768;
@@ -38,6 +39,7 @@ interface TaskGroupProps {
taskId: string; taskId: string;
taskTitle?: string; taskTitle?: string;
documents: TaskDocumentWithTask[]; documents: TaskDocumentWithTask[];
taskColumn?: string;
onOpenTask: (taskId: string) => void; onOpenTask: (taskId: string) => void;
renderMarkdownStates: Map<string, boolean>; renderMarkdownStates: Map<string, boolean>;
onToggleMarkdown: (docId: string) => void; onToggleMarkdown: (docId: string) => void;
@@ -72,6 +74,13 @@ function getContentPreview(content: string, maxLength: number = 200): string {
return `${content.substring(0, maxLength)}…`; return `${content.substring(0, maxLength)}…`;
} }
function getTaskColumnStatusDotClass(taskColumn: string): string {
if (taskColumn === "done") return "status-dot status-dot--online";
if (taskColumn === "archived") return "status-dot status-dot--offline";
if (taskColumn === "todo" || taskColumn === "triage") return "status-dot status-dot--pending";
return "status-dot status-dot--connecting";
}
function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) { function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -140,9 +149,12 @@ function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCa
); );
} }
function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) { function TaskGroup({ taskId, taskTitle, documents, taskColumn, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
return ( return (
<div className="documents-group"> <div className="documents-group">
@@ -160,6 +172,13 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
<span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span> <span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span>
</button> </button>
{taskStatusLabel ? (
<span className="documents-group-status badge" aria-label={t("documents.taskStatusAria", "Task status: {{status}}", { status: taskStatusLabel })}>
<span className={taskStatusDotClass} aria-hidden="true" />
<span>{taskStatusLabel}</span>
</span>
) : null}
<span className="documents-group-count">{t("documents.docCount", "{{count}} doc{{plural}}", { count: documents.length, plural: documents.length !== 1 ? "s" : "" })}</span> <span className="documents-group-count">{t("documents.docCount", "{{count}} doc{{plural}}", { count: documents.length, plural: documents.length !== 1 ? "s" : "" })}</span>
<button <button
@@ -366,6 +385,11 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
return { return {
taskId, taskId,
taskTitle: sortedDocs[0]?.taskTitle, taskTitle: sortedDocs[0]?.taskTitle,
/*
FNXC:DocumentsView 2026-07-02-00:00:
Task document groups must surface the parent task completion state in the header so operators can identify done work without expanding documents or opening task details. Use the first available column from the grouped task documents because legacy rows may omit taskColumn.
*/
taskColumn: sortedDocs.find((doc) => doc.taskColumn)?.taskColumn,
documents: sortedDocs, documents: sortedDocs,
latestUpdated: sortedDocs[0]?.updatedAt ?? "", latestUpdated: sortedDocs[0]?.updatedAt ?? "",
}; };
@@ -807,11 +831,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
) : ( ) : (
<div className="documents-task-list-wrap"> <div className="documents-task-list-wrap">
<div className="documents-view-list"> <div className="documents-view-list">
{groupedDocuments.map(({ taskId, taskTitle, documents: taskDocs }) => ( {groupedDocuments.map(({ taskId, taskTitle, taskColumn, documents: taskDocs }) => (
<TaskGroup <TaskGroup
key={taskId} key={taskId}
taskId={taskId} taskId={taskId}
taskTitle={taskTitle} taskTitle={taskTitle}
taskColumn={taskColumn}
documents={taskDocs} documents={taskDocs}
onOpenTask={handleOpenTask} onOpenTask={handleOpenTask}
renderMarkdownStates={taskDocMarkdownStates} renderMarkdownStates={taskDocMarkdownStates}

View File

@@ -82,6 +82,80 @@ const mockTaskDocuments: TaskDocumentWithTask[] = [
}, },
]; ];
const mockStatusTaskDocuments: TaskDocumentWithTask[] = [
{
id: "doc-status-done",
taskId: "KB-DONE",
key: "plan",
content: "Done document content",
revision: 1,
author: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T16:00:00.000Z",
taskTitle: "Done task",
taskColumn: "done",
},
{
id: "doc-status-todo",
taskId: "KB-TODO",
key: "notes",
content: "Todo document content",
revision: 1,
author: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T15:00:00.000Z",
taskTitle: "Todo task",
taskColumn: "todo",
},
{
id: "doc-status-archived",
taskId: "KB-ARCHIVED",
key: "summary",
content: "Archived document content",
revision: 1,
author: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T14:00:00.000Z",
taskTitle: "Archived task",
taskColumn: "archived",
},
{
id: "doc-status-custom",
taskId: "KB-CUSTOM",
key: "handoff",
content: "Custom column document content",
revision: 1,
author: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T13:00:00.000Z",
taskTitle: "Custom task",
taskColumn: "qa-ready",
},
{
id: "doc-status-missing",
taskId: "KB-MISSING",
key: "legacy",
content: "Legacy document content",
revision: 1,
author: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T12:00:00.000Z",
taskTitle: "Legacy task",
},
{
id: "doc-status-done-duplicate",
taskId: "KB-DONE",
key: "notes",
content: "Second done document content",
revision: 1,
author: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T11:00:00.000Z",
taskTitle: "Done task",
taskColumn: "done",
},
];
const mockProjectFiles = [ const mockProjectFiles = [
{ {
path: "README.md", path: "README.md",
@@ -274,6 +348,80 @@ describe("DocumentsView", () => {
expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument();
}); });
it("shows collapsed task group status badges for done non-done archived custom and legacy documents", async () => {
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
mockUseDocuments.mockReturnValue({
documents: mockStatusTaskDocuments,
projectFiles: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
});
const doneGroup = screen.getByRole("button", { name: /expand documents for task KB-DONE/i }).closest(".documents-group");
const todoGroup = screen.getByRole("button", { name: /expand documents for task KB-TODO/i }).closest(".documents-group");
const archivedGroup = screen.getByRole("button", { name: /expand documents for task KB-ARCHIVED/i }).closest(".documents-group");
const customGroup = screen.getByRole("button", { name: /expand documents for task KB-CUSTOM/i }).closest(".documents-group");
const missingGroup = screen.getByRole("button", { name: /expand documents for task KB-MISSING/i }).closest(".documents-group");
expect(doneGroup).not.toBeNull();
expect(todoGroup).not.toBeNull();
expect(archivedGroup).not.toBeNull();
expect(customGroup).not.toBeNull();
expect(missingGroup).not.toBeNull();
expect(within(doneGroup as HTMLElement).getByLabelText("Task status: Done")).toHaveTextContent("Done");
expect(within(doneGroup as HTMLElement).getByText("2 docs")).toBeInTheDocument();
expect(within(doneGroup as HTMLElement).getByLabelText("Task status: Done").querySelector(".status-dot--online")).toBeInTheDocument();
expect(within(todoGroup as HTMLElement).getByLabelText("Task status: Todo")).toHaveTextContent("Todo");
expect(within(archivedGroup as HTMLElement).getByLabelText("Task status: Archived")).toHaveTextContent("Archived");
expect(within(customGroup as HTMLElement).getByLabelText("Task status: qa-ready")).toHaveTextContent("qa-ready");
expect((missingGroup as HTMLElement).querySelector(".documents-group-status")).not.toBeInTheDocument();
expect(screen.queryByText("Done document content")).not.toBeInTheDocument();
});
it("keeps task group status badges as non-interactive header metadata on mobile", async () => {
window.innerWidth = 600;
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
mockUseDocuments.mockReturnValue({
documents: mockStatusTaskDocuments,
projectFiles: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
await waitFor(() => {
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
});
const doneGroup = screen.getByRole("button", { name: /expand documents for task KB-DONE/i }).closest(".documents-group") as HTMLElement;
const status = within(doneGroup).getByLabelText("Task status: Done");
expect(status).toHaveClass("documents-group-status");
expect(status.closest(".documents-group-header")).toBeInTheDocument();
expect(status.closest("button")).toBeNull();
expect(within(doneGroup).getByRole("button", { name: /open task KB-DONE/i })).toBeInTheDocument();
});
it("renders artifacts tab counts and all media card paths without non-media expand shells", async () => { it("renders artifacts tab counts and all media card paths without non-media expand shells", async () => {
const onOpenArtifactTaskDetail = vi.fn(); const onOpenArtifactTaskDetail = vi.fn();
mockUseArtifacts.mockReturnValue({ mockUseArtifacts.mockReturnValue({