FN-7811: convert Task Documents tab to left-sidebar list + right-pane viewer
Reworks the Artifacts view's Task Documents tab to reuse the Project Files left-sidebar/right-pane layout instead of expandable task-grouped cards, giving both tabs a consistent browsing pattern. - Replace the collapsible TaskGroup/DocumentCard components with a documents-project-layout sidebar listing documents grouped by task (with revision metadata and task status badges) and a right pane rendering the selected document's content - Add separate selectedTaskDocumentId selection state so tab switching never leaks Project Files content into Task Documents (and vice versa), with desktop/mobile gating matching the Project Files pattern - Preserve the existing Plain/Markdown render toggle for task document content; select-to-comment stays Project-Files-only for this change (tracked as follow-up) - Update DocumentsView.css for the new sidebar/right-pane structure and rewrite DocumentsView.test.tsx coverage for the new interaction model - Update docs/dashboard-guide.md to describe the shared sidebar/right-pane browsing pattern for Task Documents - Add changeset fn-7811-task-documents-sidebar.md (minor) Files changed: .changeset/fn-7811-task-documents-sidebar.md | 7 + docs/dashboard-guide.md | 4 +- .../dashboard/app/components/DocumentsView.css | 181 ++++-------- .../dashboard/app/components/DocumentsView.tsx | 308 ++++++++++----------- .../components/__tests__/DocumentsView.test.tsx | 116 ++++++-- 5 files changed, 299 insertions(+), 317 deletions(-) Fusion-Task-Id: FN-7811 Fusion-Task-Lineage: e943ebf8-6e8e-4f5f-a55c-99c949be2624 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7811-task-documents-sidebar.md
Normal file
7
.changeset/fn-7811-task-documents-sidebar.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Artifacts view — Task Documents now uses a left-sidebar list with a right-pane content viewer.
|
||||
category: feature
|
||||
dev: DocumentsView Task Documents tab reuses the Project Files `documents-project-layout` sidebar/right-pane pattern with a separate selection state and desktop/mobile gating; the markdown/plain toggle is preserved. Select-to-comment stays Project-Files-only (tracked as a follow-up).
|
||||
@@ -817,9 +817,9 @@ Artifacts view aggregates registered artifacts, project markdown files, and task
|
||||
|
||||
Features:
|
||||
|
||||
- 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
|
||||
- Browse **Task Documents** in the same left-sidebar/right-pane pattern as **Project Files**: the sidebar groups document entries by task ID with revision metadata and parent task status badges when available, while the right pane loads the selected document content
|
||||
- Search documents across tasks
|
||||
- Open project markdown files with inline preview
|
||||
- Open project markdown files and task documents with inline preview
|
||||
- Browse the **Artifacts** tab for registry media registered by any agent, dashboard chat/user action, or system tool across tasks
|
||||
- Already-open global and task-detail artifact lists refresh live from the artifact registry event when an agent, dashboard chat session, user action, or system tool registers a new artifact, while preserving active search filters and task scoping
|
||||
- Use the tab-count badges to see the current counts for Project Files, Task Documents, and Artifacts; the Artifacts badge reflects the loaded `GET /api/artifacts` result set, including active search filters
|
||||
|
||||
@@ -357,65 +357,70 @@ Subtle view-only indicator for the project-file preview header; muted so it info
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.documents-task-list-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.documents-view-list {
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-10-17:40:
|
||||
FN-7811 reuses the Project Files shell for Task Documents, while task-scoped sidebar groups preserve task id/title/status context before each selectable document entry. Keep these styles scoped so Project Files continues to own the generic markdown-file list visuals.
|
||||
*/
|
||||
.documents-task-documents-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* Document Group */
|
||||
.documents-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
.documents-task-sidebar-group {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.documents-group-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
.documents-task-sidebar-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.documents-task-sidebar-group-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.documents-group-toggle-btn {
|
||||
.documents-task-sidebar-title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.documents-task-sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.documents-group-toggle-btn:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.documents-group-toggle-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.documents-group-toggle {
|
||||
.documents-task-sidebar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.documents-task-document-item {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.documents-task-document-title-block {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.documents-task-document-meta {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.documents-group-task-id {
|
||||
@@ -470,13 +475,6 @@ Subtle view-only indicator for the project-file preview header; muted so it info
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.documents-group-content {
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Document Card */
|
||||
.document-card {
|
||||
background: var(--card);
|
||||
@@ -499,48 +497,6 @@ Subtle view-only indicator for the project-file preview header; muted so it info
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.document-card-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.document-card-key svg {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.document-card-key-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.document-card-revision-badge {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.document-card-expand-btn {
|
||||
padding: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.document-card-expand-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.document-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.document-mode-toggle {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 11px;
|
||||
@@ -570,16 +526,6 @@ Subtle view-only indicator for the project-file preview header; muted so it info
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.document-card-content-markdown {
|
||||
/* Container chrome only; markdown styles delegated to .markdown-body */
|
||||
}
|
||||
|
||||
.document-card-content-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.document-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -598,25 +544,6 @@ Subtle view-only indicator for the project-file preview header; muted so it info
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.document-card-preview {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.document-card-preview-truncated {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.document-card-content--expanded .document-card-preview,
|
||||
.document-card-content--expanded .document-card-preview-truncated {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-card-content-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
@@ -932,42 +859,32 @@ The artifacts tab is a thumbnail-first responsive media gallery for agent-create
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.documents-group-header {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"toggle count"
|
||||
"status status"
|
||||
"link link";
|
||||
row-gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.documents-group-toggle-btn {
|
||||
grid-area: toggle;
|
||||
.documents-task-sidebar-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.documents-group-status {
|
||||
grid-area: status;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.documents-group-count {
|
||||
grid-area: count;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.documents-group-task-link {
|
||||
grid-area: link;
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.documents-group-task-title {
|
||||
max-width: 180px;
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
.document-card-key-text {
|
||||
font-size: 12px;
|
||||
.documents-task-document-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.documents-artifact-gallery,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./DocumentsView.css";
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
|
||||
import { ArrowLeft, FileText, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
@@ -29,22 +29,6 @@ export interface DocumentsViewProps {
|
||||
onSendSelectionToTask?: (description: string) => void;
|
||||
}
|
||||
|
||||
interface DocumentCardProps {
|
||||
document: TaskDocumentWithTask;
|
||||
renderMarkdown: boolean;
|
||||
onToggleMarkdown: () => void;
|
||||
}
|
||||
|
||||
interface TaskGroupProps {
|
||||
taskId: string;
|
||||
taskTitle?: string;
|
||||
documents: TaskDocumentWithTask[];
|
||||
taskColumn?: string;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
renderMarkdownStates: Map<string, boolean>;
|
||||
onToggleMarkdown: (docId: string) => void;
|
||||
}
|
||||
|
||||
function formatTimestamp(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString();
|
||||
@@ -74,137 +58,14 @@ function getTaskColumnStatusDotClass(taskColumn: string): string {
|
||||
return "status-dot status-dot--connecting";
|
||||
}
|
||||
|
||||
function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const preview = getContentPreview(document.content);
|
||||
const showExpand = document.content.length > 200;
|
||||
|
||||
return (
|
||||
<div className="document-card">
|
||||
<div className="document-card-header">
|
||||
<div className="document-card-key">
|
||||
<FileText size={14} />
|
||||
<span className="document-card-key-text">{document.key}</span>
|
||||
<span className="document-card-revision-badge">v{document.revision}</span>
|
||||
</div>
|
||||
<div className="document-card-actions">
|
||||
<button
|
||||
className="btn btn-sm document-card-expand-btn"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
title={expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")}
|
||||
aria-label={expanded ? t("documents.collapseContent", "Collapse content") : t("documents.expandContent", "Expand content")}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="document-card-meta">
|
||||
<span className="document-card-author">{document.author}</span>
|
||||
<span className="document-card-separator">·</span>
|
||||
<span className="document-card-date">{formatTimestamp(document.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className={`document-card-content${expanded ? " document-card-content--expanded" : ""}`}>
|
||||
{expanded ? (
|
||||
<>
|
||||
<div className="document-card-content-header">
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={onToggleMarkdown}
|
||||
aria-label={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
aria-pressed={renderMarkdown}
|
||||
title={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
>
|
||||
{renderMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
|
||||
</button>
|
||||
</div>
|
||||
{renderMarkdown ? (
|
||||
<div className="document-card-content-markdown">
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="document-card-content-text">{document.content}</pre>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="document-card-preview">{preview}</p>
|
||||
)}
|
||||
{showExpand && !expanded && (
|
||||
<p className="document-card-preview-truncated">…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskGroup({ taskId, taskTitle, documents, taskColumn, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
|
||||
const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
|
||||
|
||||
return (
|
||||
<div className="documents-group">
|
||||
<div className="documents-group-header">
|
||||
<button
|
||||
className="documents-group-toggle-btn"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")} documents for task ${taskId}`}
|
||||
>
|
||||
<span className="documents-group-toggle" aria-hidden="true">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</span>
|
||||
<span className="documents-group-task-id">{taskId}</span>
|
||||
<span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span>
|
||||
</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>
|
||||
|
||||
<button
|
||||
className="documents-group-task-link"
|
||||
onClick={() => onOpenTask(taskId)}
|
||||
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId, title: taskTitle || t("documents.untitled", "Untitled") })}
|
||||
>
|
||||
{t("documents.openTask", "Open task")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="documents-group-content">
|
||||
{documents.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
renderMarkdown={renderMarkdownStates.get(doc.id) ?? false}
|
||||
onToggleMarkdown={() => onToggleMarkdown(doc.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// FNXC:ArtifactsView 2026-07-11-11:30: Artifacts is the first tab and the landing tab — the view is the artifact gallery first, with project files and task documents as secondary tabs.
|
||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("artifacts");
|
||||
const columnLabel = useColumnLabel();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
|
||||
const [selectedTaskDocumentId, setSelectedTaskDocumentId] = useState<string | null>(null);
|
||||
const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileLoading, setFileLoading] = useState(false);
|
||||
@@ -269,6 +130,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
useEffect(() => {
|
||||
setActiveTab("artifacts");
|
||||
setSelectedFile(null);
|
||||
setSelectedTaskDocumentId(null);
|
||||
setShowHiddenProjectFiles(false);
|
||||
setFileContent(null);
|
||||
setFileError(null);
|
||||
@@ -303,6 +165,25 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
.sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated));
|
||||
}, [documents]);
|
||||
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
const selectedTaskDocument = useMemo(() => {
|
||||
if (!selectedTaskDocumentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return documents.find((doc) => doc.id === selectedTaskDocumentId) ?? null;
|
||||
}, [documents, selectedTaskDocumentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskDocumentId && !selectedTaskDocument) {
|
||||
setSelectedTaskDocumentId(null);
|
||||
}
|
||||
}, [selectedTaskDocument, selectedTaskDocumentId]);
|
||||
|
||||
const filteredProjectFiles = useMemo(() => {
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
@@ -340,6 +221,9 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
|
||||
const handleTabChange = useCallback((tab: DocumentsTab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab !== "tasks") {
|
||||
setSelectedTaskDocumentId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenTask = useCallback(async (taskId: string) => {
|
||||
@@ -404,6 +288,14 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setFileLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleSelectTaskDocument = useCallback((docId: string) => {
|
||||
setSelectedTaskDocumentId(docId);
|
||||
}, []);
|
||||
|
||||
const handleBackToTaskDocumentList = useCallback(() => {
|
||||
setSelectedTaskDocumentId(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
|
||||
setTaskDocMarkdownStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -702,21 +594,123 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="documents-task-list-wrap">
|
||||
<div className="documents-view-list">
|
||||
{groupedDocuments.map(({ taskId, taskTitle, taskColumn, documents: taskDocs }) => (
|
||||
<TaskGroup
|
||||
key={taskId}
|
||||
taskId={taskId}
|
||||
taskTitle={taskTitle}
|
||||
taskColumn={taskColumn}
|
||||
documents={taskDocs}
|
||||
onOpenTask={handleOpenTask}
|
||||
renderMarkdownStates={taskDocMarkdownStates}
|
||||
onToggleMarkdown={handleToggleTaskDocMarkdown}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
/*
|
||||
FNXC:DocumentsView 2026-07-10-17:30:
|
||||
FN-7811 requires Task Documents to use the same desktop two-pane and mobile list→detail→back gating as Project Files: show the grouped sidebar when desktop or no document is selected, and show the right-pane viewer when desktop or a document is selected.
|
||||
*/
|
||||
<div className={`documents-project-layout documents-task-documents-layout${isMobile ? " documents-project-layout--mobile" : ""}`}>
|
||||
{(!isMobile || !selectedTaskDocument) && (
|
||||
<aside className="documents-view-sidebar documents-task-documents-sidebar" aria-label={t("documents.taskDocumentsListLabel", "Task documents")}>
|
||||
{groupedDocuments.map(({ taskId, taskTitle, taskColumn, documents: taskDocs }) => {
|
||||
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
|
||||
const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
|
||||
|
||||
return (
|
||||
<section key={taskId} className="documents-task-sidebar-group" aria-labelledby={`documents-task-group-${taskId}`}>
|
||||
<div className="documents-task-sidebar-group-header">
|
||||
<div className="documents-task-sidebar-title-wrap">
|
||||
<h3 id={`documents-task-group-${taskId}`} className="documents-task-sidebar-title">
|
||||
<span className="documents-group-task-id">{taskId}</span>
|
||||
<span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span>
|
||||
</h3>
|
||||
{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}
|
||||
</div>
|
||||
<div className="documents-task-sidebar-actions">
|
||||
<span className="documents-group-count">{t("documents.docCount", "{{count}} doc{{plural}}", { count: taskDocs.length, plural: taskDocs.length !== 1 ? "s" : "" })}</span>
|
||||
<button
|
||||
className="documents-group-task-link"
|
||||
onClick={() => void handleOpenTask(taskId)}
|
||||
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId, title: taskTitle || t("documents.untitled", "Untitled") })}
|
||||
>
|
||||
{t("documents.openTask", "Open task")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="markdown-file-list documents-task-document-list">
|
||||
{taskDocs.map((doc) => {
|
||||
const isSelected = selectedTaskDocument?.id === doc.id;
|
||||
return (
|
||||
<li key={doc.id} className="markdown-file-list-item">
|
||||
<button
|
||||
className={`markdown-file-item documents-task-document-item${isSelected ? " markdown-file-item--selected" : ""}`}
|
||||
onClick={() => handleSelectTaskDocument(doc.id)}
|
||||
aria-label={t("documents.openTaskDocument", "Open {{taskId}} {{key}}", { taskId: doc.taskId, key: doc.key })}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
>
|
||||
<span className="markdown-file-item-name">{doc.key}</span>
|
||||
<span className="markdown-file-item-path">{doc.taskId} · {doc.taskTitle || t("documents.untitled", "Untitled")}</span>
|
||||
<span className="markdown-file-item-meta">
|
||||
{t("documents.revisionShort", "v{{revision}}", { revision: doc.revision })} · {doc.author} · {formatTimestamp(doc.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{(!isMobile || selectedTaskDocument) && (
|
||||
<section className="documents-view-main" aria-label={t("documents.taskDocumentPreviewLabel", "Task document content preview")}>
|
||||
{isMobile && selectedTaskDocument && (
|
||||
<button
|
||||
className="btn btn-sm documents-mobile-back"
|
||||
onClick={handleBackToTaskDocumentList}
|
||||
aria-label={t("documents.backToTaskDocumentsList", "Back to task documents list")}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
{t("documents.backToTaskDocuments", "Back to documents")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!selectedTaskDocument ? (
|
||||
<div className="documents-view-empty">
|
||||
<p>{t("documents.selectTaskDocument", "Select a task document to view its content.")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="documents-content-viewer documents-task-document-viewer">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
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-pressed={taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false}
|
||||
title={(taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) ? 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")}
|
||||
</button>
|
||||
</div>
|
||||
{(taskDocMarkdownStates.get(selectedTaskDocument.id) ?? false) ? (
|
||||
<div className="documents-content-markdown">
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedTaskDocument.content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="document-card-content-text documents-content-viewer-text">{selectedTaskDocument.content}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -372,7 +372,49 @@ describe("DocumentsView", () => {
|
||||
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 () => {
|
||||
it("renders task documents desktop sidebar and empty right pane", () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
|
||||
expect(screen.getByLabelText("Task documents")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Task document content preview")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open KB-001 plan" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Select a task document to view its content.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a task document loads content and marks the sidebar entry current", () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
const planEntry = screen.getByRole("button", { name: "Open KB-001 plan" });
|
||||
fireEvent.click(planEntry);
|
||||
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
expect(planEntry).toHaveAttribute("aria-current", "true");
|
||||
expect(screen.getByText("KB-001 / plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps project file and task document selections isolated across tab switches", async () => {
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
expect(await screen.findByText(/Hello docs/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
|
||||
expect(screen.getByText("Select a task document to view its content.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Hello docs/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
expect(screen.getByText(/Hello docs/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders task document sidebar status badges for done non-done archived custom and legacy documents", async () => {
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
loading: false,
|
||||
@@ -392,11 +434,11 @@ describe("DocumentsView", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
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");
|
||||
const doneGroup = screen.getByRole("heading", { name: /KB-DONE.*Done task/i }).closest(".documents-task-sidebar-group");
|
||||
const todoGroup = screen.getByRole("heading", { name: /KB-TODO.*Todo task/i }).closest(".documents-task-sidebar-group");
|
||||
const archivedGroup = screen.getByRole("heading", { name: /KB-ARCHIVED.*Archived task/i }).closest(".documents-task-sidebar-group");
|
||||
const customGroup = screen.getByRole("heading", { name: /KB-CUSTOM.*Custom task/i }).closest(".documents-task-sidebar-group");
|
||||
const missingGroup = screen.getByRole("heading", { name: /KB-MISSING.*Legacy task/i }).closest(".documents-task-sidebar-group");
|
||||
|
||||
expect(doneGroup).not.toBeNull();
|
||||
expect(todoGroup).not.toBeNull();
|
||||
@@ -414,7 +456,7 @@ describe("DocumentsView", () => {
|
||||
expect(screen.queryByText("Done document content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps task group status badges as non-interactive header metadata on mobile", async () => {
|
||||
it("keeps task document status badges as non-interactive sidebar metadata on mobile", async () => {
|
||||
window.innerWidth = 600;
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
@@ -435,11 +477,11 @@ describe("DocumentsView", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
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 doneGroup = screen.getByRole("heading", { name: /KB-DONE.*Done task/i }).closest(".documents-task-sidebar-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(".documents-task-sidebar-group-header")).toBeInTheDocument();
|
||||
expect(status.closest("button")).toBeNull();
|
||||
expect(within(doneGroup).getByRole("button", { name: /open task KB-DONE/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -792,7 +834,7 @@ describe("DocumentsView", () => {
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Review this rendered content."));
|
||||
});
|
||||
|
||||
it("search filters task documents", async () => {
|
||||
it("search filters task documents and clears filtered-out selection", async () => {
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
loading: false,
|
||||
@@ -822,13 +864,18 @@ describe("DocumentsView", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: /search task documents/i }), {
|
||||
target: { value: "alpha" },
|
||||
target: { value: "beta" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("KB-001")).toBeInTheDocument();
|
||||
expect(screen.queryByText("KB-002")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("KB-002")).toBeInTheDocument();
|
||||
expect(screen.queryByText("KB-001")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Select a task document to view its content.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -907,6 +954,33 @@ describe("DocumentsView", () => {
|
||||
expect(screen.getByRole("button", { name: "Open README.md" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports mobile list/detail navigation for task documents", async () => {
|
||||
window.innerWidth = 600;
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
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");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /back to task documents list/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Open KB-001 plan" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /back to task documents list/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Open KB-001 plan" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens task details from task document groups", async () => {
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
@@ -1021,12 +1095,7 @@ describe("DocumentsView", () => {
|
||||
expect(screen.getByText("KB-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand a task group
|
||||
fireEvent.click(screen.getByRole("button", { name: /expand documents for task KB-001/i }));
|
||||
|
||||
// Expand the document card
|
||||
const expandBtn = screen.getByRole("button", { name: /expand content/i });
|
||||
fireEvent.click(expandBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
|
||||
// Task document toggle should default to raw (not influenced by project toggle)
|
||||
const taskToggle = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
@@ -1041,7 +1110,7 @@ describe("DocumentsView", () => {
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("task document cards support markdown toggle when expanded", async () => {
|
||||
it("task document viewer supports markdown toggle", async () => {
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
loading: false,
|
||||
@@ -1054,12 +1123,7 @@ describe("DocumentsView", () => {
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// Expand task group
|
||||
fireEvent.click(screen.getByRole("button", { name: /expand documents for task KB-001/i }));
|
||||
|
||||
// Expand the document card
|
||||
const expandBtn = screen.getByRole("button", { name: /expand content/i });
|
||||
fireEvent.click(expandBtn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||
|
||||
// Should show raw text by default
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user