FN-7845: show task-scoped artifacts alongside Task Documents
Extend the dashboard's Task Documents tab to union each task's registered documents with its task-scoped artifacts, with an inline right-pane viewer for the added artifact types. - DocumentsView Task Documents tab now merges task documents and task-scoped artifacts per task group, sorted/grouped consistently - Adds an inline right-pane artifact viewer (image/video/audio/pdf/inline-doc/other) reusing getArtifactCategory + artifactMediaUrl/fetchArtifact - Selection state is a discriminated document|artifact union kept separate from Project Files selection; the standalone Artifacts gallery tab is unchanged - Updates dashboard-guide.md to describe the merged Task Documents behavior (grouping, search, and preview now cover both documents and artifacts) - Adds a minor changeset for @runfusion/fusion documenting the artifact-in-Task-Documents feature - Expands DocumentsView test coverage for the new union/selection/preview behavior Files changed: .changeset/fn-7845-task-documents-artifacts.md | 7 + docs/dashboard-guide.md | 6 +- .../dashboard/app/components/DocumentsView.css | 142 +++++++- .../dashboard/app/components/DocumentsView.tsx | 389 ++++++++++++++++++--- .../components/__tests__/DocumentsView.test.tsx | 290 ++++++++++++++- 5 files changed, 780 insertions(+), 54 deletions(-) Fusion-Task-Id: FN-7845 Fusion-Task-Lineage: 32bbe6dc-4c02-449c-a13f-38abb8fd727d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7845-task-documents-artifacts.md
Normal file
7
.changeset/fn-7845-task-documents-artifacts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Artifacts view — the Task Documents list now also shows each task's registered artifacts.
|
||||||
|
category: feature
|
||||||
|
dev: DocumentsView Task Documents tab unions task documents with task-scoped artifacts per task group and adds an inline right-pane artifact viewer (image/video/audio/pdf/inline-doc/other) reusing getArtifactCategory + artifactMediaUrl/fetchArtifact. Selection is a discriminated document|artifact type kept separate from Project Files selection; the standalone Artifacts gallery tab is unchanged.
|
||||||
@@ -831,9 +831,9 @@ Artifacts view aggregates registered artifacts, project markdown files, and task
|
|||||||
|
|
||||||
Features:
|
Features:
|
||||||
|
|
||||||
- Browse **Task Documents** in the same left-sidebar/right-pane pattern as **Project Files**: the sidebar groups document entries by task ID in distinct task cards with clear spacing between tasks, revision metadata, and parent task status badges when available, while the right pane loads the selected document content
|
- Browse **Task Documents** in the same left-sidebar/right-pane pattern as **Project Files**: the sidebar groups task documents and task-scoped registered artifacts by task ID in distinct task cards with clear spacing between tasks, revision/artifact metadata, and parent task status badges when available, while the right pane loads the selected document or artifact preview
|
||||||
- Search documents across tasks
|
- Search task documents and task-scoped artifacts across tasks
|
||||||
- Open project markdown files and task documents with inline preview
|
- Open project markdown files, task documents, and task-scoped artifacts 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
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ Artifacts controls are the first page content below the shared header, so add a
|
|||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: 11px;
|
font-size: 0.6875rem;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Artifacts controls are the first page content below the shared header, so add a
|
|||||||
}
|
}
|
||||||
|
|
||||||
.documents-view-empty-hint {
|
.documents-view-empty-hint {
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
}
|
}
|
||||||
@@ -263,7 +263,7 @@ Artifacts controls are the first page content below the shared header, so add a
|
|||||||
}
|
}
|
||||||
|
|
||||||
.markdown-file-item-name {
|
.markdown-file-item-name {
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@@ -448,6 +448,138 @@ FN-7836 requires loaded Task Documents to render visible task-card groups even w
|
|||||||
background: color-mix(in srgb, var(--todo) 14%, var(--card));
|
background: color-mix(in srgb, var(--todo) 14%, var(--card));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DocumentsView 2026-07-11-22:04:
|
||||||
|
FN-7845 places registered task artifacts under the same task group as documents, as peer outputs with a small subsection label and icon-bearing rows. Keep these rules scoped to the Task Documents sidebar/right pane so Project Files' shared .markdown-file-item base and the standalone Artifacts gallery remain unchanged.
|
||||||
|
*/
|
||||||
|
.documents-task-artifacts-subsection {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border-top: thin solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--surface) 38%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifacts-label {
|
||||||
|
padding: var(--space-sm) var(--space-lg) var(--space-xs);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-list {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-item {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-title-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-title-row .markdown-file-item-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-viewer {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-meta {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-open-task {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--todo);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-open-task:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-open-task:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-media {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: min(60dvh, 42rem);
|
||||||
|
object-fit: contain;
|
||||||
|
border: thin solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-audio {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-pdf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-pdf iframe {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60dvh;
|
||||||
|
border: thin solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-doc-content {
|
||||||
|
min-height: 45dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-download {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border: thin solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-task-artifact-download p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:DocumentsView 2026-07-11-14:30:
|
FNXC:DocumentsView 2026-07-11-14:30:
|
||||||
The meta line (author · revision · date) renders as its own row directly under the path+actions header row; the viewer column's gap is too tall between two related header rows, so the meta pulls itself closer to the path box it annotates.
|
The meta line (author · revision · date) renders as its own row directly under the path+actions header row; the viewer column's gap is too tall between two related header rows, so the meta pulls itself closer to the path box it annotates.
|
||||||
@@ -536,7 +668,7 @@ The meta line (author · revision · date) renders as its own row directly under
|
|||||||
|
|
||||||
.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: 0.6875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -583,7 +715,7 @@ The meta line (author · revision · date) renders as its own row directly under
|
|||||||
|
|
||||||
.document-card-content-text {
|
.document-card-content-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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, Pencil, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
|
import type { TFunction } from "i18next";
|
||||||
|
import { ArrowLeft, AudioLines, Download, ExternalLink, FileText, FileType, Image as ImageIcon, Package, Pencil, RefreshCw, Search, Video, 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 { Artifact, ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
import { artifactMediaUrl, fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||||
import { useArtifacts } from "../hooks/useArtifacts";
|
import { useArtifacts } from "../hooks/useArtifacts";
|
||||||
import { useDocuments } from "../hooks/useDocuments";
|
import { useDocuments } from "../hooks/useDocuments";
|
||||||
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
||||||
@@ -14,13 +15,32 @@ import { useSelectionComment } from "../hooks/useSelectionComment";
|
|||||||
import { SelectionCommentPopover } from "./SelectionCommentPopover";
|
import { SelectionCommentPopover } from "./SelectionCommentPopover";
|
||||||
import { FileEditor } from "./FileEditor";
|
import { FileEditor } from "./FileEditor";
|
||||||
import { LoadingSpinner } from "./LoadingSpinner";
|
import { LoadingSpinner } from "./LoadingSpinner";
|
||||||
import { ArtifactsGallery } from "./ArtifactsGallery";
|
import { ArtifactsGallery, getArtifactCategory, type ArtifactCategory } from "./ArtifactsGallery";
|
||||||
import { ViewHeader } from "./ViewHeader";
|
import { ViewHeader } from "./ViewHeader";
|
||||||
import { useColumnLabel } from "../i18n/labels";
|
import { useColumnLabel } from "../i18n/labels";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
type DocumentsTab = "project" | "tasks" | "artifacts";
|
type DocumentsTab = "project" | "tasks" | "artifacts";
|
||||||
|
type SelectedTaskItem = { kind: "document" | "artifact"; id: string };
|
||||||
|
|
||||||
|
type GroupedTaskItems = {
|
||||||
|
taskId: string;
|
||||||
|
taskTitle?: string;
|
||||||
|
taskColumn?: string;
|
||||||
|
documents: TaskDocumentWithTask[];
|
||||||
|
artifacts: ArtifactWithTask[];
|
||||||
|
latestUpdated: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_ARTIFACT_CATEGORY_ICONS: Record<ArtifactCategory, typeof ImageIcon> = {
|
||||||
|
image: ImageIcon,
|
||||||
|
doc: FileText,
|
||||||
|
pdf: FileType,
|
||||||
|
video: Video,
|
||||||
|
audio: AudioLines,
|
||||||
|
other: Package,
|
||||||
|
};
|
||||||
|
|
||||||
export interface DocumentsViewProps {
|
export interface DocumentsViewProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -54,6 +74,145 @@ function getTaskColumnStatusDotClass(taskColumn: string): string {
|
|||||||
return "status-dot status-dot--connecting";
|
return "status-dot status-dot--connecting";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTaskArtifactCategoryLabel(t: TFunction<"app">, category: ArtifactCategory): string {
|
||||||
|
switch (category) {
|
||||||
|
case "image": return t("documents.artifactCategoryImage", "Image");
|
||||||
|
case "doc": return t("documents.artifactCategoryDoc", "Document");
|
||||||
|
case "pdf": return t("documents.artifactCategoryPdf", "PDF");
|
||||||
|
case "video": return t("documents.artifactCategoryVideo", "Video");
|
||||||
|
case "audio": return t("documents.artifactCategoryAudioSingle", "Audio");
|
||||||
|
case "other": return t("documents.artifactCategoryOtherSingle", "Other");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactMatchesTaskSearch(artifact: ArtifactWithTask, normalizedQuery: string): boolean {
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
return [artifact.title, artifact.description, artifact.taskId, artifact.taskTitle, artifact.mimeType, artifact.authorId]
|
||||||
|
.some((value) => value?.toLowerCase().includes(normalizedQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactHasInlineText(artifact: Pick<ArtifactWithTask | Artifact, "content" | "mimeType" | "type">): boolean {
|
||||||
|
const mime = artifact.mimeType?.toLowerCase().split(";", 1)[0] ?? "";
|
||||||
|
return Boolean(artifact.content) || artifact.type === "document" || mime.startsWith("text/") || mime === "application/json" || mime === "application/xml";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskArtifactInlineViewerProps {
|
||||||
|
artifact: ArtifactWithTask;
|
||||||
|
projectId?: string;
|
||||||
|
content: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
renderMarkdown: boolean;
|
||||||
|
onToggleMarkdown: () => void;
|
||||||
|
onOpenTask: (taskId: string) => void;
|
||||||
|
t: TFunction<"app">;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskArtifactInlineViewer({ artifact, projectId, content, loading, error, renderMarkdown, onToggleMarkdown, onOpenTask, t }: TaskArtifactInlineViewerProps) {
|
||||||
|
const [mediaError, setMediaError] = useState<string | null>(null);
|
||||||
|
const category = getArtifactCategory(artifact);
|
||||||
|
const categoryLabel = getTaskArtifactCategoryLabel(t, category);
|
||||||
|
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
|
||||||
|
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
|
||||||
|
const hasInlineText = category === "doc" && artifactHasInlineText(artifact);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMediaError(null);
|
||||||
|
}, [artifact.id]);
|
||||||
|
|
||||||
|
const mediaErrorNode = mediaError ? <p className="documents-content-state documents-content-state--error">{mediaError}</p> : null;
|
||||||
|
|
||||||
|
const body = (() => {
|
||||||
|
if (category === "image") {
|
||||||
|
return <>{mediaErrorNode}<img className="documents-task-artifact-media" src={mediaUrl} alt={title} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></>;
|
||||||
|
}
|
||||||
|
if (category === "video") {
|
||||||
|
return <>{mediaErrorNode}<video className="documents-task-artifact-media" controls src={mediaUrl} aria-label={t("documents.videoArtifactLabel", "Video artifact: {{title}}", { title })} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></>;
|
||||||
|
}
|
||||||
|
if (category === "audio") {
|
||||||
|
return <>{mediaErrorNode}<audio className="documents-task-artifact-audio" controls src={mediaUrl} aria-label={t("documents.audioArtifactLabel", "Audio artifact: {{title}}", { title })} onError={() => setMediaError(t("documents.artifactMediaFailed", "Failed to load artifact preview."))} /></>;
|
||||||
|
}
|
||||||
|
if (category === "pdf") {
|
||||||
|
return (
|
||||||
|
<div className="documents-task-artifact-pdf">
|
||||||
|
<iframe title={t("documents.pdfArtifactTitle", "PDF artifact: {{title}}", { title })} src={mediaUrl} />
|
||||||
|
<a className="btn btn-sm" href={mediaUrl} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink size={14} aria-hidden="true" />
|
||||||
|
{t("documents.openInNewTab", "Open in new tab")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (category === "doc" && hasInlineText) {
|
||||||
|
if (loading) {
|
||||||
|
return <p className="documents-content-state"><LoadingSpinner label={t("documents.loadingArtifactContent", "Loading artifact content…")} /></p>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <p className="documents-content-state documents-content-state--error">{error}</p>;
|
||||||
|
}
|
||||||
|
if (renderMarkdown) {
|
||||||
|
return (
|
||||||
|
<div className="documents-content-markdown documents-task-artifact-doc-content">
|
||||||
|
<div className="markdown-body">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content ?? artifact.content ?? ""}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <pre className="document-card-content-text documents-content-viewer-text documents-task-artifact-doc-content">{content ?? artifact.content ?? ""}</pre>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="documents-task-artifact-download">
|
||||||
|
<Package size={24} aria-hidden="true" />
|
||||||
|
<p>{artifact.description || t("documents.binaryArtifactFallback", "This artifact cannot be previewed inline.")}</p>
|
||||||
|
<a className="btn btn-sm" href={mediaUrl} target="_blank" rel="noreferrer" data-testid="task-artifact-open-link">
|
||||||
|
<Download size={14} aria-hidden="true" />
|
||||||
|
{t("documents.openArtifact", "Open artifact")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="documents-content-viewer documents-task-artifact-viewer">
|
||||||
|
<div className="documents-content-header documents-task-document-header">
|
||||||
|
<p className="documents-file-path-header">{artifact.taskId} / {title}</p>
|
||||||
|
{category === "doc" && hasInlineText ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="document-card-meta documents-task-document-meta documents-task-artifact-meta">
|
||||||
|
<span>{categoryLabel}</span>
|
||||||
|
<span className="document-card-separator">·</span>
|
||||||
|
<span>{artifact.authorId || t("documents.unknownAuthor", "unknown")}</span>
|
||||||
|
<span className="document-card-separator">·</span>
|
||||||
|
<span>{formatTimestamp(artifact.updatedAt)}</span>
|
||||||
|
{artifact.taskId ? (
|
||||||
|
<>
|
||||||
|
<span className="document-card-separator">·</span>
|
||||||
|
<button className="documents-task-artifact-open-task" onClick={() => void onOpenTask(artifact.taskId!)}>
|
||||||
|
{t("documents.openTask", "Open task")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{artifact.description ? <p className="documents-task-artifact-description">{artifact.description}</p> : null}
|
||||||
|
<div className={`documents-task-artifact-preview documents-task-artifact-preview--${category}`}>
|
||||||
|
{body}
|
||||||
|
</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.
|
||||||
@@ -61,7 +220,11 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
const columnLabel = useColumnLabel();
|
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);
|
/*
|
||||||
|
FNXC:DocumentsView 2026-07-11-22:04:
|
||||||
|
FN-7845 makes the Task Documents sidebar a mixed task-output list, so selection must be discriminated by document vs artifact. Keep this state separate from Project Files' selectedFile to preserve tab isolation and prevent artifact previews from enabling document editing or select-to-comment.
|
||||||
|
*/
|
||||||
|
const [selectedTaskItem, setSelectedTaskItem] = useState<SelectedTaskItem | 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);
|
||||||
@@ -86,6 +249,11 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
const [editingTaskDocumentId, setEditingTaskDocumentId] = useState<string | null>(null);
|
const [editingTaskDocumentId, setEditingTaskDocumentId] = useState<string | null>(null);
|
||||||
const [taskDocDraft, setTaskDocDraft] = useState("");
|
const [taskDocDraft, setTaskDocDraft] = useState("");
|
||||||
const [taskDocSaving, setTaskDocSaving] = useState(false);
|
const [taskDocSaving, setTaskDocSaving] = useState(false);
|
||||||
|
const [artifactDocContent, setArtifactDocContent] = useState<string | null>(null);
|
||||||
|
const [artifactDocLoading, setArtifactDocLoading] = useState(false);
|
||||||
|
const [artifactDocError, setArtifactDocError] = useState<string | null>(null);
|
||||||
|
const [renderArtifactMarkdown, setRenderArtifactMarkdown] = useState(true);
|
||||||
|
const artifactDocRequestIdRef = useRef(0);
|
||||||
/*
|
/*
|
||||||
FNXC:DocumentsView 2026-07-11-14:45:
|
FNXC:DocumentsView 2026-07-11-14:45:
|
||||||
Operator requirement: Project Files must be editable in place too (same CodeMirror FileEditor), replacing the former Read-only badge contract. Saves go through the workspace file API for the "project" workspace and update the local preview content on success.
|
Operator requirement: Project Files must be editable in place too (same CodeMirror FileEditor), replacing the former Read-only badge contract. Saves go through the workspace file API for the "project" workspace and update the local preview content on success.
|
||||||
@@ -147,7 +315,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab("artifacts");
|
setActiveTab("artifacts");
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setSelectedTaskDocumentId(null);
|
setSelectedTaskItem(null);
|
||||||
setShowHiddenProjectFiles(false);
|
setShowHiddenProjectFiles(false);
|
||||||
setFileContent(null);
|
setFileContent(null);
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
@@ -157,36 +325,61 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
setEditingTaskDocumentId(null);
|
setEditingTaskDocumentId(null);
|
||||||
setTaskDocDraft("");
|
setTaskDocDraft("");
|
||||||
setTaskDocSaving(false);
|
setTaskDocSaving(false);
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
setArtifactDocLoading(false);
|
||||||
|
setArtifactDocError(null);
|
||||||
|
setRenderArtifactMarkdown(true);
|
||||||
setEditingProjectFile(false);
|
setEditingProjectFile(false);
|
||||||
setProjectFileDraft("");
|
setProjectFileDraft("");
|
||||||
setProjectFileSaving(false);
|
setProjectFileSaving(false);
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const groupedDocuments = useMemo(() => {
|
const groupedTaskItems = useMemo<GroupedTaskItems[]>(() => {
|
||||||
const groups = new Map<string, TaskDocumentWithTask[]>();
|
const normalizedQuery = taskSearchQuery.toLowerCase();
|
||||||
|
const groups = new Map<string, { documents: TaskDocumentWithTask[]; artifacts: ArtifactWithTask[] }>();
|
||||||
|
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
const existing = groups.get(doc.taskId) || [];
|
const existing = groups.get(doc.taskId) ?? { documents: [], artifacts: [] };
|
||||||
groups.set(doc.taskId, [...existing, doc]);
|
existing.documents.push(doc);
|
||||||
|
groups.set(doc.taskId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DocumentsView 2026-07-11-22:04:
|
||||||
|
FN-7845 requires the Task Documents list to be a union of task documents and task-scoped registered artifacts, so operators see all outputs for a task in one grouped sidebar. Registry artifacts without taskId remain exclusive to the standalone Artifacts tab because they cannot be safely attached to a task group.
|
||||||
|
*/
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
if (!artifact.taskId || !artifactMatchesTaskSearch(artifact, normalizedQuery)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = groups.get(artifact.taskId) ?? { documents: [], artifacts: [] };
|
||||||
|
existing.artifacts.push(artifact);
|
||||||
|
groups.set(artifact.taskId, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groups.entries())
|
return Array.from(groups.entries())
|
||||||
.map(([taskId, docs]) => {
|
.map(([taskId, group]) => {
|
||||||
const sortedDocs = [...docs].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
const sortedDocs = [...group.documents].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
const sortedArtifacts = [...group.artifacts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
const latestUpdated = [sortedDocs[0]?.updatedAt, sortedArtifacts[0]?.updatedAt].filter(Boolean).sort().at(-1) ?? "";
|
||||||
|
const titleSource = sortedDocs.find((doc) => doc.taskTitle)?.taskTitle ?? sortedArtifacts.find((artifact) => artifact.taskTitle)?.taskTitle;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId,
|
taskId,
|
||||||
taskTitle: sortedDocs[0]?.taskTitle,
|
taskTitle: titleSource,
|
||||||
/*
|
/*
|
||||||
FNXC:DocumentsView 2026-07-02-00:00:
|
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.
|
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 grouped documents or artifacts because legacy rows may omit taskColumn.
|
||||||
*/
|
*/
|
||||||
taskColumn: sortedDocs.find((doc) => doc.taskColumn)?.taskColumn,
|
taskColumn: sortedDocs.find((doc) => doc.taskColumn)?.taskColumn ?? sortedArtifacts.find((artifact) => artifact.taskColumn)?.taskColumn,
|
||||||
documents: sortedDocs,
|
documents: sortedDocs,
|
||||||
latestUpdated: sortedDocs[0]?.updatedAt ?? "",
|
artifacts: sortedArtifacts,
|
||||||
|
latestUpdated,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
.filter((group) => group.documents.length > 0 || group.artifacts.length > 0)
|
||||||
.sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated));
|
.sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated));
|
||||||
}, [documents]);
|
}, [artifacts, documents, taskSearchQuery]);
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -197,18 +390,42 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
FN-7812 extends the existing select-to-comment affordance to the Task Documents right pane without a new comment model. The active task-document selection ref follows the same Plain/Markdown toggle, and the composed source path uses taskId/key so operators can identify the originating task document. Keep Task Documents gated by selectedTaskDocument and Project Files gated by selectedFile so tab switches cannot cross-render popovers; the shared composer-open lock is safe because only one tab pane is mounted at a time.
|
FN-7812 extends the existing select-to-comment affordance to the Task Documents right pane without a new comment model. The active task-document selection ref follows the same Plain/Markdown toggle, and the composed source path uses taskId/key so operators can identify the originating task document. Keep Task Documents gated by selectedTaskDocument and Project Files gated by selectedFile so tab switches cannot cross-render popovers; the shared composer-open lock is safe because only one tab pane is mounted at a time.
|
||||||
*/
|
*/
|
||||||
const selectedTaskDocument = useMemo(() => {
|
const selectedTaskDocument = useMemo(() => {
|
||||||
if (!selectedTaskDocumentId) {
|
if (selectedTaskItem?.kind !== "document") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return documents.find((doc) => doc.id === selectedTaskDocumentId) ?? null;
|
return documents.find((doc) => doc.id === selectedTaskItem.id) ?? null;
|
||||||
}, [documents, selectedTaskDocumentId]);
|
}, [documents, selectedTaskItem]);
|
||||||
|
|
||||||
|
const selectedTaskArtifact = useMemo(() => {
|
||||||
|
if (selectedTaskItem?.kind !== "artifact") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts.find((artifact) => artifact.id === selectedTaskItem.id) ?? null;
|
||||||
|
}, [artifacts, selectedTaskItem]);
|
||||||
|
|
||||||
|
const selectedTaskOutput = selectedTaskDocument ?? selectedTaskArtifact;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTaskDocumentId && !selectedTaskDocument) {
|
if (!selectedTaskItem) {
|
||||||
setSelectedTaskDocumentId(null);
|
return;
|
||||||
}
|
}
|
||||||
}, [selectedTaskDocument, selectedTaskDocumentId]);
|
|
||||||
|
const selectionStillVisible = groupedTaskItems.some((group) => (
|
||||||
|
selectedTaskItem.kind === "document"
|
||||||
|
? group.documents.some((doc) => doc.id === selectedTaskItem.id)
|
||||||
|
: group.artifacts.some((artifact) => artifact.id === selectedTaskItem.id)
|
||||||
|
));
|
||||||
|
if (!selectionStillVisible) {
|
||||||
|
setSelectedTaskItem(null);
|
||||||
|
setEditingTaskDocumentId(null);
|
||||||
|
setTaskDocDraft("");
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
setArtifactDocLoading(false);
|
||||||
|
setArtifactDocError(null);
|
||||||
|
}
|
||||||
|
}, [groupedTaskItems, selectedTaskItem]);
|
||||||
|
|
||||||
const filteredProjectFiles = useMemo(() => {
|
const filteredProjectFiles = useMemo(() => {
|
||||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
@@ -248,9 +465,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
const handleTabChange = useCallback((tab: DocumentsTab) => {
|
const handleTabChange = useCallback((tab: DocumentsTab) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
if (tab !== "tasks") {
|
if (tab !== "tasks") {
|
||||||
setSelectedTaskDocumentId(null);
|
setSelectedTaskItem(null);
|
||||||
setEditingTaskDocumentId(null);
|
setEditingTaskDocumentId(null);
|
||||||
setTaskDocDraft("");
|
setTaskDocDraft("");
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
setArtifactDocLoading(false);
|
||||||
|
setArtifactDocError(null);
|
||||||
}
|
}
|
||||||
if (tab !== "project") {
|
if (tab !== "project") {
|
||||||
setEditingProjectFile(false);
|
setEditingProjectFile(false);
|
||||||
@@ -352,15 +572,30 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
}, [selectedFile, projectFileDraft, projectId, addToast, t]);
|
}, [selectedFile, projectFileDraft, projectId, addToast, t]);
|
||||||
|
|
||||||
const handleSelectTaskDocument = useCallback((docId: string) => {
|
const handleSelectTaskDocument = useCallback((docId: string) => {
|
||||||
setSelectedTaskDocumentId(docId);
|
setSelectedTaskItem({ kind: "document", id: docId });
|
||||||
setEditingTaskDocumentId(null);
|
setEditingTaskDocumentId(null);
|
||||||
setTaskDocDraft("");
|
setTaskDocDraft("");
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
setArtifactDocLoading(false);
|
||||||
|
setArtifactDocError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectTaskArtifact = useCallback((artifactId: string) => {
|
||||||
|
setSelectedTaskItem({ kind: "artifact", id: artifactId });
|
||||||
|
setEditingTaskDocumentId(null);
|
||||||
|
setTaskDocDraft("");
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
setArtifactDocError(null);
|
||||||
|
setRenderArtifactMarkdown(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBackToTaskDocumentList = useCallback(() => {
|
const handleBackToTaskDocumentList = useCallback(() => {
|
||||||
setSelectedTaskDocumentId(null);
|
setSelectedTaskItem(null);
|
||||||
setEditingTaskDocumentId(null);
|
setEditingTaskDocumentId(null);
|
||||||
setTaskDocDraft("");
|
setTaskDocDraft("");
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
setArtifactDocLoading(false);
|
||||||
|
setArtifactDocError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
|
const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
|
||||||
@@ -399,6 +634,36 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
}
|
}
|
||||||
}, [selectedTaskDocument, taskDocDraft, projectId, refreshDocuments, addToast, t]);
|
}, [selectedTaskDocument, taskDocDraft, projectId, refreshDocuments, addToast, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTaskArtifact || getArtifactCategory(selectedTaskArtifact) !== "doc") {
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
setArtifactDocLoading(false);
|
||||||
|
setArtifactDocError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = artifactDocRequestIdRef.current + 1;
|
||||||
|
artifactDocRequestIdRef.current = requestId;
|
||||||
|
setArtifactDocLoading(true);
|
||||||
|
setArtifactDocError(null);
|
||||||
|
setArtifactDocContent(null);
|
||||||
|
|
||||||
|
void fetchArtifact(selectedTaskArtifact.id, projectId)
|
||||||
|
.then((artifact) => {
|
||||||
|
if (artifactDocRequestIdRef.current !== requestId) return;
|
||||||
|
setArtifactDocContent(artifact.content ?? selectedTaskArtifact.content ?? "");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (artifactDocRequestIdRef.current !== requestId) return;
|
||||||
|
setArtifactDocError(err instanceof Error ? err.message : String(err));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (artifactDocRequestIdRef.current === requestId) {
|
||||||
|
setArtifactDocLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [projectId, selectedTaskArtifact]);
|
||||||
|
|
||||||
const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
|
const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
|
||||||
|
|
||||||
const handleRetry = useCallback(async () => {
|
const handleRetry = useCallback(async () => {
|
||||||
@@ -413,7 +678,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
await refreshArtifacts();
|
await refreshArtifacts();
|
||||||
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
|
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
|
||||||
|
|
||||||
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? documents.length : artifacts.length;
|
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? groupedTaskItems.length : artifacts.length;
|
||||||
const selectedTaskDocumentRendersMarkdown = selectedTaskDocument ? (taskDocMarkdownStates.get(selectedTaskDocument.id) ?? true) : false;
|
const selectedTaskDocumentRendersMarkdown = selectedTaskDocument ? (taskDocMarkdownStates.get(selectedTaskDocument.id) ?? true) : false;
|
||||||
const activeTaskDocumentSelection = selectedTaskDocumentRendersMarkdown ? taskDocMarkdownSelection : taskDocPlainSelection;
|
const activeTaskDocumentSelection = selectedTaskDocumentRendersMarkdown ? taskDocMarkdownSelection : taskDocPlainSelection;
|
||||||
const editingSelectedTaskDocument = selectedTaskDocument !== null && editingTaskDocumentId === selectedTaskDocument.id;
|
const editingSelectedTaskDocument = selectedTaskDocument !== null && editingTaskDocumentId === selectedTaskDocument.id;
|
||||||
@@ -494,7 +759,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
onClick={() => handleTabChange("tasks")}
|
onClick={() => handleTabChange("tasks")}
|
||||||
>
|
>
|
||||||
{t("documents.taskDocumentsTab", "Task Documents")}
|
{t("documents.taskDocumentsTab", "Task Documents")}
|
||||||
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
<span className="documents-tab-count">{groupedTaskItems.length}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -712,7 +977,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
<div className="documents-view-loading">
|
<div className="documents-view-loading">
|
||||||
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
|
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
|
||||||
</div>
|
</div>
|
||||||
) : groupedDocuments.length === 0 ? (
|
) : groupedTaskItems.length === 0 ? (
|
||||||
<div className="documents-view-empty">
|
<div className="documents-view-empty">
|
||||||
{searchQuery.trim() ? (
|
{searchQuery.trim() ? (
|
||||||
<p>{t("documents.noMatchTask", "No task documents match \"{{query}}\".", { query: searchQuery.trim() })}</p>
|
<p>{t("documents.noMatchTask", "No task documents match \"{{query}}\".", { query: searchQuery.trim() })}</p>
|
||||||
@@ -729,12 +994,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
) : (
|
) : (
|
||||||
/*
|
/*
|
||||||
FNXC:DocumentsView 2026-07-10-17:30:
|
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.
|
FN-7811 requires Task Documents to use the same desktop two-pane and mobile list→detail→back gating as Project Files; FN-7845 extends the selected detail to document OR artifact outputs while keeping the list-first mobile flow.
|
||||||
*/
|
*/
|
||||||
<div className={`documents-project-layout documents-task-documents-layout${isMobile ? " documents-project-layout--mobile" : ""}`}>
|
<div className={`documents-project-layout documents-task-documents-layout${isMobile ? " documents-project-layout--mobile" : ""}`}>
|
||||||
{(!isMobile || !selectedTaskDocument) && (
|
{(!isMobile || !selectedTaskOutput) && (
|
||||||
<aside className="documents-view-sidebar documents-task-documents-sidebar" aria-label={t("documents.taskDocumentsListLabel", "Task documents")}>
|
<aside className="documents-view-sidebar documents-task-documents-sidebar" aria-label={t("documents.taskDocumentsListLabel", "Task documents")}>
|
||||||
{groupedDocuments.map(({ taskId, taskTitle, taskColumn, documents: taskDocs }) => {
|
{groupedTaskItems.map(({ taskId, taskTitle, taskColumn, documents: taskDocs, artifacts: taskArtifacts }) => {
|
||||||
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
|
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
|
||||||
const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
|
const taskStatusDotClass = taskColumn ? getTaskColumnStatusDotClass(taskColumn) : "status-dot";
|
||||||
|
|
||||||
@@ -754,7 +1019,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="documents-task-sidebar-actions">
|
<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>
|
<span className="documents-group-count">{t("documents.taskOutputCount", "{{docCount}} doc{{docPlural}} · {{artifactCount}} artifact{{artifactPlural}}", { docCount: taskDocs.length, docPlural: taskDocs.length !== 1 ? "s" : "", artifactCount: taskArtifacts.length, artifactPlural: taskArtifacts.length !== 1 ? "s" : "" })}</span>
|
||||||
<button
|
<button
|
||||||
className="documents-group-task-link"
|
className="documents-group-task-link"
|
||||||
onClick={() => void handleOpenTask(taskId)}
|
onClick={() => void handleOpenTask(taskId)}
|
||||||
@@ -768,7 +1033,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
{taskDocs.map((doc) => {
|
{taskDocs.map((doc) => {
|
||||||
const isSelected = selectedTaskDocument?.id === doc.id;
|
const isSelected = selectedTaskDocument?.id === doc.id;
|
||||||
return (
|
return (
|
||||||
<li key={doc.id} className="markdown-file-list-item">
|
<li key={`document-${doc.id}`} className="markdown-file-list-item">
|
||||||
<button
|
<button
|
||||||
className={`markdown-file-item documents-task-document-item${isSelected ? " markdown-file-item--selected" : ""}`}
|
className={`markdown-file-item documents-task-document-item${isSelected ? " markdown-file-item--selected" : ""}`}
|
||||||
onClick={() => handleSelectTaskDocument(doc.id)}
|
onClick={() => handleSelectTaskDocument(doc.id)}
|
||||||
@@ -784,6 +1049,38 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{taskArtifacts.length > 0 ? (
|
||||||
|
<li className="documents-task-artifacts-subsection" aria-label={t("documents.taskArtifactsSubsection", "Task artifacts")}>
|
||||||
|
<div className="documents-task-artifacts-label">{t("documents.taskArtifactsLabel", "Artifacts")}</div>
|
||||||
|
<ul className="markdown-file-list documents-task-artifact-list">
|
||||||
|
{taskArtifacts.map((artifact) => {
|
||||||
|
const category = getArtifactCategory(artifact);
|
||||||
|
const Icon = TASK_ARTIFACT_CATEGORY_ICONS[category];
|
||||||
|
const categoryLabel = getTaskArtifactCategoryLabel(t, category);
|
||||||
|
const isSelected = selectedTaskArtifact?.id === artifact.id;
|
||||||
|
return (
|
||||||
|
<li key={`artifact-${artifact.id}`} className="markdown-file-list-item">
|
||||||
|
<button
|
||||||
|
className={`markdown-file-item documents-task-document-item documents-task-artifact-item${isSelected ? " markdown-file-item--selected" : ""}`}
|
||||||
|
onClick={() => handleSelectTaskArtifact(artifact.id)}
|
||||||
|
aria-label={t("documents.openTaskArtifact", "Open {{taskId}} artifact {{title}}", { taskId, title: artifact.title || t("documents.untitledArtifact", "Untitled artifact") })}
|
||||||
|
aria-current={isSelected ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<span className="documents-task-artifact-title-row">
|
||||||
|
<Icon size={14} aria-hidden="true" />
|
||||||
|
<span className="markdown-file-item-name">{artifact.title || t("documents.untitledArtifact", "Untitled artifact")}</span>
|
||||||
|
</span>
|
||||||
|
<span className="markdown-file-item-path">{categoryLabel} · {artifact.mimeType || artifact.type}</span>
|
||||||
|
<span className="markdown-file-item-meta">
|
||||||
|
{artifact.authorId || t("documents.unknownAuthor", "unknown")} · {formatTimestamp(artifact.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -791,9 +1088,9 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(!isMobile || selectedTaskDocument) && (
|
{(!isMobile || selectedTaskOutput) && (
|
||||||
<section className="documents-view-main" aria-label={t("documents.taskDocumentPreviewLabel", "Task document content preview")}>
|
<section className="documents-view-main" aria-label={t("documents.taskDocumentPreviewLabel", "Task document content preview")}>
|
||||||
{isMobile && selectedTaskDocument && (
|
{isMobile && selectedTaskOutput && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm documents-mobile-back"
|
className="btn btn-sm documents-mobile-back"
|
||||||
onClick={handleBackToTaskDocumentList}
|
onClick={handleBackToTaskDocumentList}
|
||||||
@@ -804,11 +1101,23 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectedTaskDocument ? (
|
{!selectedTaskOutput ? (
|
||||||
<div className="documents-view-empty">
|
<div className="documents-view-empty">
|
||||||
<p>{t("documents.selectTaskDocument", "Select a task document to view its content.")}</p>
|
<p>{t("documents.selectTaskDocument", "Select a task document or artifact to view its content.")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : selectedTaskArtifact ? (
|
||||||
|
<TaskArtifactInlineViewer
|
||||||
|
artifact={selectedTaskArtifact}
|
||||||
|
projectId={projectId}
|
||||||
|
content={artifactDocContent}
|
||||||
|
loading={artifactDocLoading}
|
||||||
|
error={artifactDocError}
|
||||||
|
renderMarkdown={renderArtifactMarkdown}
|
||||||
|
onToggleMarkdown={() => setRenderArtifactMarkdown((prev) => !prev)}
|
||||||
|
onOpenTask={handleOpenArtifactTask}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
) : selectedTaskDocument ? (
|
||||||
<div className="documents-content-viewer documents-task-document-viewer">
|
<div className="documents-content-viewer documents-task-document-viewer">
|
||||||
{/*
|
{/*
|
||||||
FNXC:DocumentsView 2026-07-11-14:30:
|
FNXC:DocumentsView 2026-07-11-14:30:
|
||||||
@@ -876,7 +1185,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
|||||||
)}
|
)}
|
||||||
{taskDocumentSelectionPopover}
|
{taskDocumentSelectionPopover}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,6 +259,120 @@ const mockArtifacts: ArtifactWithTask[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const taskScopedArtifacts: ArtifactWithTask[] = [
|
||||||
|
{
|
||||||
|
id: "task-artifact-image",
|
||||||
|
type: "image",
|
||||||
|
title: "Task screenshot",
|
||||||
|
description: "Screenshot output",
|
||||||
|
mimeType: "image/png",
|
||||||
|
uri: "artifacts/task-screenshot.png",
|
||||||
|
authorId: "agent-image",
|
||||||
|
authorType: "agent",
|
||||||
|
taskId: "KB-001",
|
||||||
|
taskTitle: "Alpha task",
|
||||||
|
taskColumn: "in-progress",
|
||||||
|
createdAt: "2026-04-19T13:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T13:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-artifact-doc",
|
||||||
|
type: "document",
|
||||||
|
title: "Task artifact notes",
|
||||||
|
content: "Inline list fallback",
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
authorId: "agent-doc",
|
||||||
|
authorType: "agent",
|
||||||
|
taskId: "KB-001",
|
||||||
|
taskTitle: "Alpha task",
|
||||||
|
taskColumn: "in-progress",
|
||||||
|
createdAt: "2026-04-19T12:30:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T12:30:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-artifact-pdf",
|
||||||
|
type: "document",
|
||||||
|
title: "Task report PDF",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
uri: "artifacts/report.pdf",
|
||||||
|
authorId: "agent-pdf",
|
||||||
|
authorType: "agent",
|
||||||
|
taskId: "KB-ARTIFACTS",
|
||||||
|
taskTitle: "Artifacts only task",
|
||||||
|
taskColumn: "todo",
|
||||||
|
createdAt: "2026-04-19T14:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T14:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-artifact-other",
|
||||||
|
type: "other",
|
||||||
|
title: "Task binary bundle",
|
||||||
|
description: "Binary output",
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
uri: "artifacts/output.bin",
|
||||||
|
authorId: "agent-other",
|
||||||
|
authorType: "agent",
|
||||||
|
taskId: "KB-ARTIFACTS",
|
||||||
|
taskTitle: "Artifacts only task",
|
||||||
|
taskColumn: "todo",
|
||||||
|
createdAt: "2026-04-19T13:30:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T13:30:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-artifact-video",
|
||||||
|
type: "video",
|
||||||
|
title: "Task walkthrough",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
uri: "artifacts/walkthrough.mp4",
|
||||||
|
authorId: "agent-video",
|
||||||
|
authorType: "agent",
|
||||||
|
taskId: "KB-VIDEO",
|
||||||
|
taskTitle: "Video task",
|
||||||
|
taskColumn: "done",
|
||||||
|
createdAt: "2026-04-19T12:15:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T12:15:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-artifact-audio",
|
||||||
|
type: "audio",
|
||||||
|
title: "Task narration",
|
||||||
|
mimeType: "audio/mpeg",
|
||||||
|
uri: "artifacts/narration.mp3",
|
||||||
|
authorId: "agent-audio",
|
||||||
|
authorType: "agent",
|
||||||
|
taskId: "KB-AUDIO",
|
||||||
|
taskTitle: "Audio task",
|
||||||
|
taskColumn: "done",
|
||||||
|
createdAt: "2026-04-19T12:10:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T12:10:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task-artifact-html",
|
||||||
|
type: "document",
|
||||||
|
title: "HTML mockup artifact",
|
||||||
|
content: "<h1>Inline HTML source</h1>",
|
||||||
|
mimeType: "text/html",
|
||||||
|
authorId: "agent-html",
|
||||||
|
authorType: "agent",
|
||||||
|
taskId: "KB-HTML",
|
||||||
|
taskTitle: "HTML task",
|
||||||
|
taskColumn: "todo",
|
||||||
|
createdAt: "2026-04-19T12:05:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T12:05:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "taskless-artifact",
|
||||||
|
type: "image",
|
||||||
|
title: "Taskless artifact",
|
||||||
|
mimeType: "image/png",
|
||||||
|
uri: "artifacts/taskless.png",
|
||||||
|
authorId: "agent-floating",
|
||||||
|
authorType: "agent",
|
||||||
|
createdAt: "2026-04-19T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-19T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function setupHookDefaults(): void {
|
function setupHookDefaults(): void {
|
||||||
mockUseDocuments.mockReturnValue({
|
mockUseDocuments.mockReturnValue({
|
||||||
documents: mockTaskDocuments,
|
documents: mockTaskDocuments,
|
||||||
@@ -385,7 +499,7 @@ describe("DocumentsView", () => {
|
|||||||
expect(screen.getByLabelText("Task documents")).toBeInTheDocument();
|
expect(screen.getByLabelText("Task documents")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Task document content preview")).toBeInTheDocument();
|
expect(screen.getByLabelText("Task document content preview")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Open KB-001 plan" })).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.getByText("Select a task document or artifact to view its content.")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,7 +608,7 @@ describe("DocumentsView", () => {
|
|||||||
expect(groups).toHaveLength(54);
|
expect(groups).toHaveLength(54);
|
||||||
expect(screen.queryByText("Loading task documents…")).not.toBeInTheDocument();
|
expect(screen.queryByText("Loading task documents…")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("No task documents yet.")).not.toBeInTheDocument();
|
expect(screen.queryByText("No task documents yet.")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Select a task document to view its content.")).toBeInTheDocument();
|
expect(screen.getByText("Select a task document or artifact to view its content.")).toBeInTheDocument();
|
||||||
|
|
||||||
const firstGroup = screen.getByRole("heading", { name: /FN-MANY-001.*Loaded task 001/i }).closest(".documents-task-sidebar-group") as HTMLElement;
|
const firstGroup = screen.getByRole("heading", { name: /FN-MANY-001.*Loaded task 001/i }).closest(".documents-task-sidebar-group") as HTMLElement;
|
||||||
expect(getComputedStyle(firstGroup).flexShrink).toBe("0");
|
expect(getComputedStyle(firstGroup).flexShrink).toBe("0");
|
||||||
@@ -522,6 +636,170 @@ describe("DocumentsView", () => {
|
|||||||
expect(screen.getByText("KB-001 / plan")).toBeInTheDocument();
|
expect(screen.getByText("KB-001 / plan")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DocumentsView 2026-07-11-22:04:
|
||||||
|
Surface enumeration for FN-7845: the Task Documents sidebar is the only grouped list that gains artifact click targets, and the invariant covers doc+artifact groups, artifact-only groups, taskless exclusion, category viewers, search filtering, mobile back flow, and tab isolation.
|
||||||
|
*/
|
||||||
|
it("lists task-scoped artifacts beside documents and includes artifact-only task groups", () => {
|
||||||
|
mockUseArtifacts.mockReturnValue({
|
||||||
|
artifacts: taskScopedArtifacts,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||||
|
|
||||||
|
const alphaGroup = screen.getByRole("heading", { name: /KB-001.*Alpha task/i }).closest(".documents-task-sidebar-group") as HTMLElement;
|
||||||
|
expect(within(alphaGroup).getByRole("button", { name: "Open KB-001 plan" })).toBeInTheDocument();
|
||||||
|
expect(within(alphaGroup).getByRole("button", { name: "Open KB-001 artifact Task screenshot" })).toBeInTheDocument();
|
||||||
|
expect(within(alphaGroup).getByText("1 doc · 2 artifacts")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const artifactOnlyGroup = screen.getByRole("heading", { name: /KB-ARTIFACTS.*Artifacts only task/i }).closest(".documents-task-sidebar-group") as HTMLElement;
|
||||||
|
expect(within(artifactOnlyGroup).queryByRole("button", { name: /Open KB-ARTIFACTS plan/ })).not.toBeInTheDocument();
|
||||||
|
expect(within(artifactOnlyGroup).getByRole("button", { name: "Open KB-ARTIFACTS artifact Task report PDF" })).toBeInTheDocument();
|
||||||
|
expect(within(artifactOnlyGroup).getByRole("button", { name: "Open KB-ARTIFACTS artifact Task binary bundle" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Open undefined artifact Taskless artifact" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Taskless artifact")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders task artifact viewers for image inline-doc pdf and other categories", async () => {
|
||||||
|
mockUseArtifacts.mockReturnValue({
|
||||||
|
artifacts: taskScopedArtifacts,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockFetchArtifact.mockResolvedValue({ ...taskScopedArtifacts.find((artifact) => artifact.id === "task-artifact-doc")!, content: "Fetched artifact **markdown**" });
|
||||||
|
|
||||||
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||||
|
|
||||||
|
const imageEntry = screen.getByRole("button", { name: "Open KB-001 artifact Task screenshot" });
|
||||||
|
fireEvent.click(imageEntry);
|
||||||
|
expect(imageEntry).toHaveAttribute("aria-current", "true");
|
||||||
|
expect(screen.getByRole("img", { name: "Task screenshot" })).toHaveAttribute("src", "/api/artifacts/task-artifact-image/media");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||||
|
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||||
|
expect(imageEntry).not.toHaveAttribute("aria-current");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 artifact Task artifact notes" }));
|
||||||
|
await waitFor(() => expect(mockFetchArtifact).toHaveBeenCalledWith("task-artifact-doc", undefined));
|
||||||
|
expect((await screen.findAllByText((_, element) => element?.textContent === "Fetched artifact markdown")).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-ARTIFACTS artifact Task report PDF" }));
|
||||||
|
expect(screen.getByTitle("PDF artifact: Task report PDF")).toHaveAttribute("src", "/api/artifacts/task-artifact-pdf/media");
|
||||||
|
expect(screen.getByRole("link", { name: /open in new tab/i })).toHaveAttribute("href", "/api/artifacts/task-artifact-pdf/media");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-ARTIFACTS artifact Task binary bundle" }));
|
||||||
|
expect(screen.getByTestId("task-artifact-open-link")).toHaveAttribute("href", "/api/artifacts/task-artifact-other/media");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders video audio and html task artifact selections in the right pane", async () => {
|
||||||
|
mockUseDocuments.mockReturnValue({
|
||||||
|
documents: [],
|
||||||
|
projectFiles: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockUseArtifacts.mockReturnValue({
|
||||||
|
artifacts: taskScopedArtifacts,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
mockFetchArtifact.mockResolvedValue({ ...taskScopedArtifacts.find((artifact) => artifact.id === "task-artifact-html")!, content: "<h1>Fetched HTML source</h1>" });
|
||||||
|
|
||||||
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-VIDEO artifact Task walkthrough" }));
|
||||||
|
expect(screen.getByLabelText("Video artifact: Task walkthrough").tagName).toBe("VIDEO");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-AUDIO artifact Task narration" }));
|
||||||
|
expect(screen.getByLabelText("Audio artifact: Task narration").tagName).toBe("AUDIO");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-HTML artifact HTML mockup artifact" }));
|
||||||
|
await waitFor(() => expect(mockFetchArtifact).toHaveBeenCalledWith("task-artifact-html", undefined));
|
||||||
|
expect(await screen.findByText(/Fetched HTML source/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters task documents and task artifacts with the task search query", async () => {
|
||||||
|
mockUseDocuments.mockImplementation((options) => ({
|
||||||
|
documents: options.searchQuery ? [] : mockTaskDocuments,
|
||||||
|
projectFiles: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
mockUseArtifacts.mockReturnValue({
|
||||||
|
artifacts: taskScopedArtifacts,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: /search task documents/i }), { target: { value: "screenshot" } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("heading", { name: /KB-001.*Alpha task/i })).toBeInTheDocument());
|
||||||
|
expect(screen.getByRole("button", { name: "Open KB-001 artifact Task screenshot" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("heading", { name: /KB-002.*Beta task/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("heading", { name: /KB-ARTIFACTS.*Artifacts only task/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the mobile list-detail-back flow for task artifact selections", () => {
|
||||||
|
window.innerWidth = 600;
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
mockUseArtifacts.mockReturnValue({
|
||||||
|
artifacts: taskScopedArtifacts,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||||
|
expect(screen.getByLabelText("Task documents")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Task document content preview")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 artifact Task screenshot" }));
|
||||||
|
expect(screen.queryByLabelText("Task documents")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("img", { name: "Task screenshot" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /back to task documents list/i }));
|
||||||
|
expect(screen.getByLabelText("Task documents")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("img", { name: "Task screenshot" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps project file task artifact and standalone artifacts tab selections isolated", async () => {
|
||||||
|
mockUseArtifacts.mockReturnValue({
|
||||||
|
artifacts: taskScopedArtifacts,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||||
|
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.queryByText(/Hello docs/)).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 artifact Task screenshot" }));
|
||||||
|
expect(screen.getByRole("img", { name: "Task screenshot" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||||
|
expect(screen.queryByText("KB-001 / Task screenshot")).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".documents-task-artifact-viewer")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("article", { name: "Artifact Task screenshot" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps project file and task document selections isolated across tab switches", async () => {
|
it("keeps project file and task document selections isolated across tab switches", async () => {
|
||||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
|
||||||
@@ -531,7 +809,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.getByText("Select a task document to view its content.")).toBeInTheDocument();
|
expect(screen.getByText("Select a task document or artifact to view its content.")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Hello docs/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Hello docs/)).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
fireEvent.click(screen.getByRole("button", { name: "Open KB-001 plan" }));
|
||||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||||
@@ -574,7 +852,7 @@ describe("DocumentsView", () => {
|
|||||||
expect(missingGroup).not.toBeNull();
|
expect(missingGroup).not.toBeNull();
|
||||||
|
|
||||||
expect(within(doneGroup as HTMLElement).getByLabelText("Task status: Done")).toHaveTextContent("Done");
|
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).getByText("2 docs · 0 artifacts")).toBeInTheDocument();
|
||||||
expect(within(doneGroup as HTMLElement).getByLabelText("Task status: Done").querySelector(".status-dot--online")).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(todoGroup as HTMLElement).getByLabelText("Task status: Todo")).toHaveTextContent("Todo");
|
||||||
expect(within(archivedGroup as HTMLElement).getByLabelText("Task status: Archived")).toHaveTextContent("Archived");
|
expect(within(archivedGroup as HTMLElement).getByLabelText("Task status: Archived")).toHaveTextContent("Archived");
|
||||||
@@ -1037,7 +1315,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.getByText("Select a task document to view its content.")).toBeInTheDocument();
|
expect(screen.getByText("Select a task document or artifact to view its content.")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /add a comment/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /add a comment/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1168,7 +1446,7 @@ describe("DocumentsView", () => {
|
|||||||
expect(screen.getByText("KB-002")).toBeInTheDocument();
|
expect(screen.getByText("KB-002")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("KB-001")).not.toBeInTheDocument();
|
expect(screen.queryByText("KB-001")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
expect(screen.queryByText("Alpha document content")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Select a task document to view its content.")).toBeInTheDocument();
|
expect(screen.getByText("Select a task document or artifact to view its content.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user