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:
gsxdsm
2026-07-10 23:11:46 -07:00
parent 281d1a3371
commit 595d323ce6
5 changed files with 302 additions and 320 deletions

View 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).

View File

@@ -817,9 +817,9 @@ Artifacts view aggregates registered artifacts, project markdown files, and task
Features: 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 - 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 - 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 - 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 - 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

View File

@@ -357,65 +357,70 @@ Subtle view-only indicator for the project-file preview header; muted so it info
color: var(--color-error); color: var(--color-error);
} }
.documents-task-list-wrap { /*
width: 100%; 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-view-list { .documents-task-documents-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md);
} }
/* Document Group */ .documents-task-sidebar-group {
.documents-group { border-bottom: 1px solid var(--border);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--card);
} }
.documents-group-header { .documents-task-sidebar-group:last-child {
display: grid; border-bottom: none;
grid-template-columns: minmax(0, 1fr) auto auto auto; }
align-items: center;
.documents-task-sidebar-group-header {
display: flex;
flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-sm) var(--space-md); padding: var(--space-md);
background: var(--surface); background: var(--surface);
border-bottom: 1px solid var(--border); 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; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
margin: 0;
min-width: 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 { .documents-task-sidebar-actions {
background: var(--card-hover);
}
.documents-group-toggle-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.documents-group-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
color: var(--text-muted); gap: var(--space-sm);
flex-shrink: 0; }
.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 { .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); 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 */
.document-card { .document-card {
background: var(--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); 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 { .document-mode-toggle {
padding: var(--space-xs) var(--space-sm); padding: var(--space-xs) var(--space-sm);
font-size: 11px; 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); 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 { .document-card-meta {
display: flex; display: flex;
align-items: center; 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); 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 { .document-card-content-text {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
@@ -932,42 +859,32 @@ The artifacts tab is a thumbnail-first responsive media gallery for agent-create
min-height: 160px; min-height: 160px;
} }
.documents-group-header { .documents-task-sidebar-actions {
grid-template-columns: minmax(0, 1fr) auto; align-items: stretch;
grid-template-areas: flex-direction: column;
"toggle count"
"status status"
"link link";
row-gap: var(--space-xs);
}
.documents-group-toggle-btn {
grid-area: toggle;
} }
.documents-group-status { .documents-group-status {
grid-area: status;
justify-self: start; justify-self: start;
} }
.documents-group-count { .documents-group-count {
grid-area: count;
justify-self: end; justify-self: end;
} }
.documents-group-task-link { .documents-group-task-link {
grid-area: link;
width: 100%; width: 100%;
justify-self: stretch; justify-self: stretch;
min-height: 44px; min-height: 44px;
} }
.documents-group-task-title { .documents-group-task-title {
max-width: 180px; max-width: 11rem;
} }
.document-card-key-text { .documents-task-document-header {
font-size: 12px; align-items: stretch;
flex-direction: column;
} }
.documents-artifact-gallery, .documents-artifact-gallery,

View File

@@ -1,7 +1,7 @@
import "./DocumentsView.css"; import "./DocumentsView.css";
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react"; import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowLeft, FileText, 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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core"; import type { ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
@@ -29,22 +29,6 @@ export interface DocumentsViewProps {
onSendSelectionToTask?: (description: string) => void; 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 { function formatTimestamp(iso?: string): string {
if (!iso) return ""; if (!iso) return "";
return new Date(iso).toLocaleString(); return new Date(iso).toLocaleString();
@@ -74,137 +58,14 @@ function getTaskColumnStatusDotClass(taskColumn: string): string {
return "status-dot status-dot--connecting"; 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) { export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
const { t } = useTranslation("app"); 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. // 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 [activeTab, setActiveTab] = useState<DocumentsTab>("artifacts");
const columnLabel = useColumnLabel();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null); const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
const [selectedTaskDocumentId, setSelectedTaskDocumentId] = useState<string | null>(null);
const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false); const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false);
const [fileContent, setFileContent] = useState<string | null>(null); const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false); const [fileLoading, setFileLoading] = useState(false);
@@ -269,6 +130,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
useEffect(() => { useEffect(() => {
setActiveTab("artifacts"); setActiveTab("artifacts");
setSelectedFile(null); setSelectedFile(null);
setSelectedTaskDocumentId(null);
setShowHiddenProjectFiles(false); setShowHiddenProjectFiles(false);
setFileContent(null); setFileContent(null);
setFileError(null); setFileError(null);
@@ -303,6 +165,25 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
.sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated)); .sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated));
}, [documents]); }, [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 filteredProjectFiles = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase(); const normalizedQuery = searchQuery.trim().toLowerCase();
if (!normalizedQuery) { if (!normalizedQuery) {
@@ -340,6 +221,9 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
const handleTabChange = useCallback((tab: DocumentsTab) => { const handleTabChange = useCallback((tab: DocumentsTab) => {
setActiveTab(tab); setActiveTab(tab);
if (tab !== "tasks") {
setSelectedTaskDocumentId(null);
}
}, []); }, []);
const handleOpenTask = useCallback(async (taskId: string) => { const handleOpenTask = useCallback(async (taskId: string) => {
@@ -404,6 +288,14 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
setFileLoading(false); setFileLoading(false);
}, []); }, []);
const handleSelectTaskDocument = useCallback((docId: string) => {
setSelectedTaskDocumentId(docId);
}, []);
const handleBackToTaskDocumentList = useCallback(() => {
setSelectedTaskDocumentId(null);
}, []);
const handleToggleTaskDocMarkdown = useCallback((docId: string) => { const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
setTaskDocMarkdownStates((prev) => { setTaskDocMarkdownStates((prev) => {
const next = new Map(prev); const next = new Map(prev);
@@ -702,21 +594,123 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
)} )}
</div> </div>
) : ( ) : (
<div className="documents-task-list-wrap"> /*
<div className="documents-view-list"> FNXC:DocumentsView 2026-07-10-17:30:
{groupedDocuments.map(({ taskId, taskTitle, taskColumn, documents: taskDocs }) => ( 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.
<TaskGroup */
key={taskId} <div className={`documents-project-layout documents-task-documents-layout${isMobile ? " documents-project-layout--mobile" : ""}`}>
taskId={taskId} {(!isMobile || !selectedTaskDocument) && (
taskTitle={taskTitle} <aside className="documents-view-sidebar documents-task-documents-sidebar" aria-label={t("documents.taskDocumentsListLabel", "Task documents")}>
taskColumn={taskColumn} {groupedDocuments.map(({ taskId, taskTitle, taskColumn, documents: taskDocs }) => {
documents={taskDocs} const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
onOpenTask={handleOpenTask} const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
renderMarkdownStates={taskDocMarkdownStates}
onToggleMarkdown={handleToggleTaskDocMarkdown} return (
/> <section key={taskId} className="documents-task-sidebar-group" aria-labelledby={`documents-task-group-${taskId}`}>
))} <div className="documents-task-sidebar-group-header">
</div> <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>
)} )}
</div> </div>

View File

@@ -372,7 +372,49 @@ 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 () => { 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({ mockUseProjectMarkdownFiles.mockReturnValue({
files: [], files: [],
loading: false, loading: false,
@@ -392,11 +434,11 @@ describe("DocumentsView", () => {
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i })); fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true"); 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 doneGroup = screen.getByRole("heading", { name: /KB-DONE.*Done task/i }).closest(".documents-task-sidebar-group");
const todoGroup = screen.getByRole("button", { name: /expand documents for task KB-TODO/i }).closest(".documents-group"); const todoGroup = screen.getByRole("heading", { name: /KB-TODO.*Todo task/i }).closest(".documents-task-sidebar-group");
const archivedGroup = screen.getByRole("button", { name: /expand documents for task KB-ARCHIVED/i }).closest(".documents-group"); const archivedGroup = screen.getByRole("heading", { name: /KB-ARCHIVED.*Archived task/i }).closest(".documents-task-sidebar-group");
const customGroup = screen.getByRole("button", { name: /expand documents for task KB-CUSTOM/i }).closest(".documents-group"); const customGroup = screen.getByRole("heading", { name: /KB-CUSTOM.*Custom task/i }).closest(".documents-task-sidebar-group");
const missingGroup = screen.getByRole("button", { name: /expand documents for task KB-MISSING/i }).closest(".documents-group"); const missingGroup = screen.getByRole("heading", { name: /KB-MISSING.*Legacy task/i }).closest(".documents-task-sidebar-group");
expect(doneGroup).not.toBeNull(); expect(doneGroup).not.toBeNull();
expect(todoGroup).not.toBeNull(); expect(todoGroup).not.toBeNull();
@@ -414,7 +456,7 @@ describe("DocumentsView", () => {
expect(screen.queryByText("Done document content")).not.toBeInTheDocument(); 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; window.innerWidth = 600;
mockUseProjectMarkdownFiles.mockReturnValue({ mockUseProjectMarkdownFiles.mockReturnValue({
files: [], files: [],
@@ -435,11 +477,11 @@ describe("DocumentsView", () => {
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i })); fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true"); 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"); const status = within(doneGroup).getByLabelText("Task status: Done");
expect(status).toHaveClass("documents-group-status"); 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(status.closest("button")).toBeNull();
expect(within(doneGroup).getByRole("button", { name: /open task KB-DONE/i })).toBeInTheDocument(); 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.")); 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({ mockUseProjectMarkdownFiles.mockReturnValue({
files: [], files: [],
loading: false, loading: false,
@@ -822,13 +864,18 @@ describe("DocumentsView", () => {
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i })); fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true"); 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 }), { fireEvent.change(screen.getByRole("textbox", { name: /search task documents/i }), {
target: { value: "alpha" }, target: { value: "beta" },
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("KB-001")).toBeInTheDocument(); expect(screen.getByText("KB-002")).toBeInTheDocument();
expect(screen.queryByText("KB-002")).not.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(); 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 () => { it("opens task details from task document groups", async () => {
mockUseProjectMarkdownFiles.mockReturnValue({ mockUseProjectMarkdownFiles.mockReturnValue({
files: [], files: [],
@@ -1021,12 +1095,7 @@ describe("DocumentsView", () => {
expect(screen.getByText("KB-001")).toBeInTheDocument(); expect(screen.getByText("KB-001")).toBeInTheDocument();
}); });
// Expand a task group fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
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);
// Task document toggle should default to raw (not influenced by project toggle) // Task document toggle should default to raw (not influenced by project toggle)
const taskToggle = screen.getByRole("button", { name: /switch to markdown/i }); 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"); 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({ mockUseProjectMarkdownFiles.mockReturnValue({
files: [], files: [],
loading: false, loading: false,
@@ -1054,12 +1123,7 @@ describe("DocumentsView", () => {
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i })); fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
// Expand task group fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
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);
// Should show raw text by default // Should show raw text by default
expect(screen.getByText("Alpha document content")).toBeInTheDocument(); expect(screen.getByText("Alpha document content")).toBeInTheDocument();