Files
fusion/packages/dashboard/app/components/DocumentsView.tsx
gsxdsm 31e49b684a TAKING default-workflow-hooks.ts + executor.ts + live-agent-count.ts + 6 dashboard files: reopen semantics by role, and the census's blind spot in both directions (13 sites) (#2628)
Batched conversion of every lifecycle-column guard I hold, plus the
three the census could not see. **Six files to zero, repo-wide 60 → 49
by a comment-stripped unanchored sweep.** Each conversion has an
isolated revert proof and a paired negative case, and the one code move
is a separate commit from the behavior changes.

## Per-file before → after

Counts from a comment-stripped, unanchored `(===|!==) ["']triage["']`
sweep over `packages/*/src` + `plugins/*/src`, excluding tests.

| file | before | after | note |
|---|---:|---:|---|
| `core/default-workflow-hooks.ts` | 4 | **0** | |
| `core/task-store/moves.ts` | 5 | **4** | only the flag-ON mirror
converted; the flag-OFF inline block is the parity reference and stays |
| `engine/executor.ts` | 3 | **0** | **absent from the 45-guard list** —
see below |
| `core/live-agent-count.ts` | 2 | **0** | duplication removed; answer
deliberately unchanged |
| `engine/replan-target.ts` | 2 | **0** | both were comment prose, not
guards |
| `core/agent-prompts.ts` | 3 | **0** | ROLE comparisons, never column
guards |
| `engine/usage-limit-detector.ts` | 2 | **0** | ROLE comparisons |
| `dashboard/app/components/DocumentsView.tsx` | 1 | **0** | real column
guard |
| `dashboard/app/components/TaskChatTab.tsx` | 2 | **0** | ROLE |
| `dashboard/app/components/AgentLogViewer.tsx` | 1 | **0** | ROLE |
| `dashboard/app/components/effective-model-resolution.ts` | 1 | **0** |
ROLE |
| `dashboard/app/hooks/useTasks.ts` | 1 | **0** | ROLE |
| `dashboard/…/command-center/MissionControlPanel.tsx` | 1 | 1 | alias
table, marked `DELIBERATE-LITERAL` with its reason |

## The census errs in BOTH directions

This is the finding I would most like carried into the remaining work.

- It **flagged 10 sites that were never column guards.** `role ===
"triage"` / `agentType === "triage"` compare an **AGENT ROLE**. The
planner *lane* is named `triage` and keeps that name — U11 removed the
*column*. Worse than noise: the obvious "finish the migration" edit is
to rename the role, and that silently empties the planner's prompt
template and mis-binds its model markers. `PLANNER_AGENT_ROLE` now names
it, so the two vocabularies are distinguishable by grep and a rename
fails loudly (revert proof: 4 tests, two of them pre-existing).
- It **missed 3 real guards in `executor.ts`**, because the pattern
matches `column`/`toColumn`/`fromColumn` and those locals are named
`from` and `originColumn`. A census keyed on variable names will keep
missing guards wherever a local was named for its role in the function.

## Two real defects, not tidying

**1. A renamed board could merge with its re-review never run.**
`default-workflow-hooks.ts` is named for the default workflow, but the
store runs it on the flag-ON path for *every* workflow — the trait
registry resolves hooks by trait id, not by workflow. Its reopen
predicates listed the default lineage's column names, so on a renamed
board **no reopen effect fired at all**. One of them clears
`workflowStepResults`, which `getTaskMergeBlocker` reads: a card bounced
out of review carried its old `passed` result back in, and that
satisfies the merge gate. Same regression the graph-owned-crossing
carve-out exists to prevent, arriving through the other door. (Two
smaller ones rode along: failure state never cleared on a renamed
reopen, and an operator dragging a card back to the queue never parked
it, so the scheduler re-dispatched what they had just pulled back.)

**I forgot the carve-out on my first pass, and that was worse than not
converting.** A role-resolved clear plus a *name*-matched exemption
means a renamed board takes the clear and never the exemption,
destroying the remediation input the graph had just written. My own
paired negative test caught it.

**2. The last-resort recovery for completed-but-stranded work did not
exist off the default lineage.** In `recoverCompletedTask`,
`promotedFromPlannerColumn` was false on a renamed board, so finished
work resting in the planning lane was never promoted — the code fell
through to `handoffTaskToReview` straight from the planning column, and
role adjacency has no planning → review edge, so the handoff was
rejected and the card stayed stuck with its work complete. I converted
the promotion **target** too: resolving the lane and then moving to a
literal `in-progress` is the half-conversion I have already been burned
by twice this program, where the guard starts admitting cards and the
move then sends them to a column the board does not declare.

## E2E evidence

`renamed-board-reopen.pg.test.ts` drives a **real PostgreSQL store** and
a real `moveTask` on a workflow whose columns carry the standard traits
under non-default names. The unit tests cannot show this: if `moves.ts`
passed `undefined`, every unit case still passes via the no-basis
fallback while the real board keeps the old behavior. **Proof it is
load-bearing: forcing `moveLifecycleColumns` to `undefined` fails 2 of
3.** The executor suite covers both the split-role and the MERGED
post-U11 shape.

## Revert proofs, isolated per site

| change reverted | result |
|---|---|
| reopen predicate → literal names | 4 of 10 fail |
| reopen field clears → literal names | 2 of 10 fail |
| `userPaused` hold lane → literal `todo` | 1 of 10 fail |
| graph carve-out → literal names | 1 of 10 fail |
| store passes `undefined` lifecycle columns | 2 of 3 fail (real PG) |
| `promotedFromPlannerColumn` → literals | 3 of 7 fail |
| two-hop condition → `=== "triage"` | 1 of 7 fails |
| promotion target → `"in-progress"` | 3 of 7 fail |
| `isPlannerColumnFor` → literals | 1 of 7 fails |
| live-agent-count: one arm dropped | 2 of 11 fail |
| DocumentsView: trait branch removed | 3 of 7 fail |
| planner role renamed to `"planner"` | 4 fail (2 pre-existing) |

Every conversion is paired with a negative case (a forward move, a
not-a-planner-lane card, a default-lineage card, a renamed column with
no traits), so neither "always fire" nor "never fire" can pass for
"resolve the role".

## Deliberately NOT converted, with reasons

- **`moves.ts` flag-OFF inline block (4).** That branch *is* the legacy
path, kept verbatim so the two can be parity-checked. Converting it
erases the reference implementation.
- **`live-agent-count.ts`'s no-flags fallback.** Reachable, and there is
nothing to resolve from — `enrich…FromFlags` exists for callers with
board flags rather than an IR, so a column missing from that map is the
renamed case. "Not intake" is as much a guess as "todo is intake", and
Running/Waiting are complements, so a card matching neither arm is
reported as neither and the footer's queued total under-reports it. The
real fix is at the caller; four new cases pin that flags override the
legacy answer **in both directions**. What did change is the
duplication: two hand-written copies of one rule now call one named
function.
- **`MissionControlPanel`'s `FUNNEL_STAGES`.** An alias table of column
*names* where `triage` sits beside `signal` and `backlog`. Command
Center aggregates across projects, so there is no single workflow to
resolve traits from — the honest conversion is a data change, not a
predicate change.
- **`DocumentsView` with no traits.** Same no-basis rule; the documents
list is full of historical columns absent from the current board. A case
asserts a renamed column with no traits still reads as "working",
documenting the gap rather than hiding it.

## Fixture findings

Each cost a red run that looked like the code under test:

- a `merge-blocker` column needs a reachable merge-class node, or
`parseWorkflowIr` rejects the workflow;
- a back-edge must be `kind: "rework"`, and a rework edge is legal only
**into** a node with `config.reworkRegion: true`;
- a workflow gets role-level transitions only when it declares wip +
review + complete + **archived** plus a planning lane — without the
archived column, adjacency falls back to order-derived neighbours and
`checking -> queued` is not a legal move at all;
- `recoverCompletedTask` only *reaches* the promotion seam when nothing
is left to gate; without passed `plan-review`/`code-review` rows it
re-enters the workflow graph and returns first, so a naive fixture
silently tests the wrong branch and every assertion reads "no moves
happened" for an unrelated reason.

## Verification

- `pnpm test:gate` **71/71**
- new suites: 10/10 reopen-semantics, 3/3 renamed-board-reopen (real
PG), 7/7 executor-planner-lanes, 7/7 documents-status-dot, 4/4
planner-role-is-not-a-column
- neighbours: 132 + 10 + 482 (gate shards), 350/351 engine
planning/replan suites, 64/64 agent-prompts, 51/51 usage-limit-detector,
11/11 live-agent-count, 11/11 dashboard hook/log suites
- the single engine failure (`executor-fast-mode-workflows.test.ts` ›
"raw fast mode still invokes non-executable review seam nodes")
**reproduces with my changes stashed** — pre-existing on `origin/main`
- typechecks clean for core, engine, and dashboard-app
(`tsconfig.app.json`; `tsconfig.json` checks nothing under `app/`);
`pnpm lint` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:39:14 -07:00

1286 lines
65 KiB
TypeScript

import "./DocumentsView.css";
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { useTranslation } from "react-i18next";
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 remarkGfm from "remark-gfm";
import type { Artifact, ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { artifactMediaUrlWithToken, fetchArtifact, fetchTaskDetail, fetchTaskDocument, fetchWorkspaceFileContent, putTaskDocument, saveWorkspaceFileContent, type MarkdownFileEntry } from "../api";
import { useArtifacts } from "../hooks/useArtifacts";
import { useDocuments } from "../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
import { useSelectionComment } from "../hooks/useSelectionComment";
import { SelectionCommentPopover } from "./SelectionCommentPopover";
import { FileEditor } from "./FileEditor";
import { LoadingSpinner } from "./LoadingSpinner";
import { ArtifactsGallery, getArtifactCategory, type ArtifactCategory } from "./ArtifactsGallery";
import { ViewHeader } from "./ViewHeader";
import { useColumnLabel } from "../i18n/labels";
import type { ExecutorColumnFlags } from "../hooks/useExecutorStats";
const MOBILE_BREAKPOINT = 768;
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,
};
/** Board-workflow column traits, indexed by task id — the shape App already builds for the footer. */
export type DocumentsColumnFlags = Pick<ExecutorColumnFlags, "complete" | "archived" | "intake" | "hold">;
export interface DocumentsViewProps {
projectId?: string;
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-12:05: optional on purpose — the status dot degrades
to the documented legacy names when a task's column has no traits (remote rows, historical
columns absent from the current board), rather than guessing.
*/
columnFlagsByTaskId?: ReadonlyMap<string, DocumentsColumnFlags>;
addToast: (message: string, type?: ToastType) => void;
onOpenDetail: (task: TaskDetail) => void;
onOpenArtifactTaskDetail?: (task: TaskDetail) => void;
onSendSelectionToTask?: (description: string) => void;
}
function formatTimestamp(iso?: string): string {
if (!iso) return "";
return new Date(iso).toLocaleString();
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-12:05 (Phase C convergence — DocumentsView.tsx):
WHAT THE GUARD MEANT, checked rather than swapped: the four dots are LIFECYCLE ROLES —
complete, archived, pre-implementation (waiting), and everything else (working). Only the
pre-implementation arm named columns, and it named the default lineage's two, so on a renamed
board a queued card showed the "working" dot.
FLAGS FIRST, legacy names only with NO BASIS. `flags` are the board-workflow column traits the
dashboard already threads to the footer (`ExecutorColumnFlags`); when present they decide, and
they cover renamed and custom columns. When ABSENT there is nothing to resolve from — the
documents list spans archived and historical tasks whose columns are not in the current board
map — and "not pre-implementation" would be as much a guess as the legacy pair. Same rule as
`live-agent-count.ts`'s no-flags fallback, and same reason: an invented answer moves what the
operator sees.
*/
export function getTaskColumnStatusDotClass(taskColumn: string, flags?: DocumentsColumnFlags): string {
if (flags) {
if (flags.archived) return "status-dot status-dot--offline";
if (flags.complete) return "status-dot status-dot--online";
if (flags.intake || flags.hold) return "status-dot status-dot--pending";
return "status-dot status-dot--connecting";
}
if (taskColumn === "done") return "status-dot status-dot--online";
if (taskColumn === "archived") return "status-dot status-dot--offline";
if (LEGACY_PRE_IMPLEMENTATION_COLUMNS.has(taskColumn)) return "status-dot status-dot--pending";
return "status-dot status-dot--connecting";
}
/** The pre-U11 planner column ids, used only when a column has no trait flags. */
const LEGACY_PRE_IMPLEMENTATION_COLUMNS: ReadonlySet<string> = new Set(["triage", "todo"]);
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 = artifactMediaUrlWithToken(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, columnFlagsByTaskId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
const { t } = useTranslation("app");
// FNXC:ArtifactsView 2026-07-11-11:30: Artifacts is the first tab and the landing tab — the view is the artifact gallery first, with project files and task documents as secondary tabs.
const [activeTab, setActiveTab] = useState<DocumentsTab>("artifacts");
const columnLabel = useColumnLabel();
const [searchQuery, setSearchQuery] = useState("");
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
/*
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 [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileError, setFileError] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(false);
const requestIdRef = useRef(0);
const markdownPreviewRef = useRef<HTMLDivElement>(null);
const plainPreviewRef = useRef<HTMLPreElement>(null);
const taskDocMarkdownPreviewRef = useRef<HTMLDivElement>(null);
const taskDocPlainPreviewRef = useRef<HTMLPreElement>(null);
// Markdown render toggle for project file preview
const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false);
/*
FNXC:DocumentsView 2026-07-11-14:45:
Markdown render toggles per task document card (scoped by doc ID). Operator requirement: task documents default to the RENDERED MARKDOWN view (not plain text) — the map only stores explicit toggles away from that default, so every `?? true` fallback here and in the toggle handler must stay in sync.
*/
const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState<Map<string, boolean>>(new Map());
/*
FNXC:DocumentsView 2026-07-11-13:40:
Operator requirement: task documents in the Artifacts view must be editable in place with the same CodeMirror FileEditor used for workspace files and artifact docs — the FN-7811 read-only pane is not enough. Editing state is scoped to the selected document ID so switching documents, tabs, or projects can never save a draft against the wrong document; the draft lives here (not in FileEditor) so Save can PUT it via putTaskDocument and refresh the SWR document list.
*/
/*
FNXC:TaskDocumentCAS 2026-07-20-11:06:
The global Artifacts editor pins the selected task document's loaded revision/hash for the lifetime of its draft. Conflict refreshes may reveal the newer revision, but must leave the shared FileEditor open with the user's draft on desktop and mobile and must not auto-retry.
FNXC:TaskDocumentCAS 2026-07-20-15:42:
A conflict must require an explicit rebase before another Save. Capture the freshly fetched revision/hash separately, keep the draft byte-for-byte unchanged, and only advance the write baseline when the operator chooses Rebase; this prevents both deterministic stale retries and silent overwrites.
*/
const [editingTaskDocumentId, setEditingTaskDocumentId] = useState<string | null>(null);
const [taskDocDraft, setTaskDocDraft] = useState("");
const [taskDocPrecondition, setTaskDocPrecondition] = useState<{ revision: number; contentHash: string } | null>(null);
const [pendingTaskDocRebase, setPendingTaskDocRebase] = useState<{ revision: number; contentHash: string } | null>(null);
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:
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.
*/
const [editingProjectFile, setEditingProjectFile] = useState(false);
const [projectFileDraft, setProjectFileDraft] = useState("");
const [projectFileSaving, setProjectFileSaving] = useState(false);
const [selectionCommentOpen, setSelectionCommentOpen] = useState(false);
const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen });
const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen });
const taskDocMarkdownSelection = useSelectionComment(taskDocMarkdownPreviewRef, { locked: selectionCommentOpen });
const taskDocPlainSelection = useSelectionComment(taskDocPlainPreviewRef, { locked: selectionCommentOpen });
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
const artifactSearchQuery = activeTab === "artifacts" ? searchQuery.trim() : "";
const {
documents,
loading: documentsLoading,
error: documentsError,
refresh: refreshDocuments,
} = useDocuments({
projectId,
searchQuery: taskSearchQuery || undefined,
includeProjectFiles: false,
});
const {
files: projectFiles,
loading: projectFilesLoading,
error: projectFilesError,
refresh: refreshProjectFiles,
} = useProjectMarkdownFiles(projectId, { showHidden: showHiddenProjectFiles });
const {
artifacts,
loading: artifactsLoading,
error: artifactsError,
refresh: refreshArtifacts,
} = useArtifacts({
projectId,
searchQuery: artifactSearchQuery || undefined,
});
useEffect(() => {
const updateMobile = () => {
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
};
updateMobile();
window.addEventListener("resize", updateMobile);
return () => {
window.removeEventListener("resize", updateMobile);
};
}, []);
useEffect(() => {
setActiveTab("artifacts");
setSelectedFile(null);
setSelectedTaskItem(null);
setShowHiddenProjectFiles(false);
setFileContent(null);
setFileError(null);
setFileLoading(false);
setRenderProjectMarkdown(false);
setTaskDocMarkdownStates(new Map());
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
setTaskDocSaving(false);
setArtifactDocContent(null);
setArtifactDocLoading(false);
setArtifactDocError(null);
setRenderArtifactMarkdown(true);
setEditingProjectFile(false);
setProjectFileDraft("");
setProjectFileSaving(false);
}, [projectId]);
const groupedTaskItems = useMemo<GroupedTaskItems[]>(() => {
const normalizedQuery = taskSearchQuery.toLowerCase();
const groups = new Map<string, { documents: TaskDocumentWithTask[]; artifacts: ArtifactWithTask[] }>();
for (const doc of documents) {
const existing = groups.get(doc.taskId) ?? { documents: [], artifacts: [] };
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())
.map(([taskId, group]) => {
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 {
taskId,
taskTitle: titleSource,
/*
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 grouped documents or artifacts because legacy rows may omit taskColumn.
*/
taskColumn: sortedDocs.find((doc) => doc.taskColumn)?.taskColumn ?? sortedArtifacts.find((artifact) => artifact.taskColumn)?.taskColumn,
documents: sortedDocs,
artifacts: sortedArtifacts,
latestUpdated,
};
})
.filter((group) => group.documents.length > 0 || group.artifacts.length > 0)
.sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated));
}, [artifacts, documents, taskSearchQuery]);
/*
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. Task Documents keeps its own Plain/Markdown render toggle so Project Files state never controls task-document rendering.
FNXC:DocumentsView 2026-07-10-23:41:
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(() => {
if (selectedTaskItem?.kind !== "document") {
return null;
}
return documents.find((doc) => doc.id === selectedTaskItem.id) ?? null;
}, [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(() => {
if (!selectedTaskItem) {
return;
}
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("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
setArtifactDocContent(null);
setArtifactDocLoading(false);
setArtifactDocError(null);
}
}, [groupedTaskItems, selectedTaskItem]);
const filteredProjectFiles = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase();
if (!normalizedQuery) {
return projectFiles;
}
return projectFiles.filter((file) => {
const normalizedPath = file.path.toLowerCase();
const normalizedName = file.name.toLowerCase();
return normalizedPath.includes(normalizedQuery) || normalizedName.includes(normalizedQuery);
});
}, [projectFiles, searchQuery]);
useEffect(() => {
if (!selectedFile) {
return;
}
const selectedStillExists = projectFiles.some((file) => file.path === selectedFile.path);
if (!selectedStillExists) {
setSelectedFile(null);
setFileContent(null);
setFileError(null);
setFileLoading(false);
}
}, [projectFiles, selectedFile]);
const handleSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
}, []);
const clearSearch = useCallback(() => {
setSearchQuery("");
}, []);
const handleTabChange = useCallback((tab: DocumentsTab) => {
setActiveTab(tab);
if (tab !== "tasks") {
setSelectedTaskItem(null);
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
setArtifactDocContent(null);
setArtifactDocLoading(false);
setArtifactDocError(null);
}
if (tab !== "project") {
setEditingProjectFile(false);
setProjectFileDraft("");
}
}, []);
const handleOpenTask = useCallback(async (taskId: string) => {
try {
const task = await fetchTaskDetail(taskId, projectId);
onOpenDetail(task);
} catch {
addToast(`Failed to open task ${taskId}`, "error");
}
}, [projectId, onOpenDetail, addToast]);
/*
FNXC:ArtifactRegistry 2026-06-22-12:00:
Artifact cards should open their parent task in the same movable task popup
used by board/list pop-out flows, not the fixed task-detail modal. Keep task
document groups on the existing onOpenDetail path so only artifact-origin
task opens change surface.
*/
const handleOpenArtifactTask = useCallback(async (taskId: string) => {
try {
const task = await fetchTaskDetail(taskId, projectId);
(onOpenArtifactTaskDetail ?? onOpenDetail)(task);
} catch {
addToast(`Failed to open task ${taskId}`, "error");
}
}, [projectId, onOpenArtifactTaskDetail, onOpenDetail, addToast]);
const handleSelectProjectFile = useCallback(async (file: MarkdownFileEntry) => {
setSelectedFile(file);
setFileLoading(true);
setFileError(null);
setFileContent(null);
setEditingProjectFile(false);
setProjectFileDraft("");
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
try {
const fileResponse = await fetchWorkspaceFileContent("project", file.path, projectId);
if (requestIdRef.current !== requestId) {
return;
}
setFileContent(fileResponse.content);
} catch (err) {
if (requestIdRef.current !== requestId) {
return;
}
const message = err instanceof Error ? err.message : `Failed to open ${file.path}`;
setFileError(message);
addToast(message, "error");
} finally {
if (requestIdRef.current === requestId) {
setFileLoading(false);
}
}
}, [projectId, addToast]);
const handleBackToFileList = useCallback(() => {
setSelectedFile(null);
setFileContent(null);
setFileError(null);
setFileLoading(false);
setEditingProjectFile(false);
setProjectFileDraft("");
}, []);
const handleStartProjectFileEdit = useCallback(() => {
if (fileContent === null) return;
setProjectFileDraft(fileContent);
setEditingProjectFile(true);
}, [fileContent]);
const handleCancelProjectFileEdit = useCallback(() => {
setEditingProjectFile(false);
setProjectFileDraft("");
}, []);
const handleSaveProjectFileEdit = useCallback(async () => {
if (!selectedFile) return;
setProjectFileSaving(true);
try {
await saveWorkspaceFileContent("project", selectedFile.path, projectFileDraft, projectId);
setFileContent(projectFileDraft);
setEditingProjectFile(false);
setProjectFileDraft("");
addToast(t("documents.projectFileSaved", "File saved"), "success");
} catch (err) {
addToast(err instanceof Error ? err.message : String(err), "error");
} finally {
setProjectFileSaving(false);
}
}, [selectedFile, projectFileDraft, projectId, addToast, t]);
const handleSelectTaskDocument = useCallback((docId: string) => {
setSelectedTaskItem({ kind: "document", id: docId });
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
setArtifactDocContent(null);
setArtifactDocLoading(false);
setArtifactDocError(null);
}, []);
const handleSelectTaskArtifact = useCallback((artifactId: string) => {
setSelectedTaskItem({ kind: "artifact", id: artifactId });
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
setArtifactDocContent(null);
setArtifactDocError(null);
setRenderArtifactMarkdown(true);
}, []);
const handleBackToTaskDocumentList = useCallback(() => {
setSelectedTaskItem(null);
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
setArtifactDocContent(null);
setArtifactDocLoading(false);
setArtifactDocError(null);
}, []);
const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
setTaskDocMarkdownStates((prev) => {
const next = new Map(prev);
const current = next.get(docId) ?? true;
next.set(docId, !current);
return next;
});
}, []);
const handleStartTaskDocEdit = useCallback(() => {
if (!selectedTaskDocument) return;
setTaskDocDraft(selectedTaskDocument.content);
setEditingTaskDocumentId(selectedTaskDocument.id);
setTaskDocPrecondition({ revision: selectedTaskDocument.revision, contentHash: selectedTaskDocument.contentHash });
setPendingTaskDocRebase(null);
}, [selectedTaskDocument]);
const handleCancelTaskDocEdit = useCallback(() => {
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
}, []);
const handleRebaseTaskDocEdit = useCallback(() => {
if (!pendingTaskDocRebase) return;
setTaskDocPrecondition(pendingTaskDocRebase);
setPendingTaskDocRebase(null);
}, [pendingTaskDocRebase]);
const handleSaveTaskDocEdit = useCallback(async () => {
if (!selectedTaskDocument || !taskDocPrecondition || pendingTaskDocRebase) return;
setTaskDocSaving(true);
try {
await putTaskDocument(selectedTaskDocument.taskId, selectedTaskDocument.key, taskDocDraft, {
expectedRevision: taskDocPrecondition.revision,
expectedContentHash: taskDocPrecondition.contentHash,
}, projectId);
await refreshDocuments();
setEditingTaskDocumentId(null);
setTaskDocDraft("");
setTaskDocPrecondition(null);
setPendingTaskDocRebase(null);
addToast(t("documents.taskDocumentSaved", "Document saved"), "success");
} catch (err) {
if (typeof err === "object" && err !== null && "status" in err && err.status === 409) {
try {
const latest = await fetchTaskDocument(selectedTaskDocument.taskId, selectedTaskDocument.key, projectId);
await refreshDocuments();
setPendingTaskDocRebase({ revision: latest.revision, contentHash: latest.contentHash });
addToast(t("documents.taskDocumentStale", "This document changed since you opened it. Your draft is preserved; review the latest revision, then choose Rebase draft before saving."), "error");
} catch (refreshError) {
addToast(refreshError instanceof Error ? refreshError.message : String(refreshError), "error");
}
} else {
addToast(err instanceof Error ? err.message : String(err), "error");
}
} finally {
setTaskDocSaving(false);
}
}, [selectedTaskDocument, taskDocDraft, taskDocPrecondition, pendingTaskDocRebase, 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 handleRetry = useCallback(async () => {
if (activeTab === "project") {
await refreshProjectFiles();
return;
}
if (activeTab === "tasks") {
await refreshDocuments();
return;
}
await refreshArtifacts();
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? groupedTaskItems.length : artifacts.length;
const selectedTaskDocumentRendersMarkdown = selectedTaskDocument ? (taskDocMarkdownStates.get(selectedTaskDocument.id) ?? true) : false;
const activeTaskDocumentSelection = selectedTaskDocumentRendersMarkdown ? taskDocMarkdownSelection : taskDocPlainSelection;
const editingSelectedTaskDocument = selectedTaskDocument !== null && editingTaskDocumentId === selectedTaskDocument.id;
const selectionPopover = activeTab === "project" && selectedFile && !editingProjectFile && onSendSelectionToTask && activeProjectSelection ? (
<SelectionCommentPopover
selectedText={activeProjectSelection.selectedText}
anchorRect={activeProjectSelection.anchorRect}
filePath={selectedFile.path}
onSubmit={onSendSelectionToTask}
onOpenChange={setSelectionCommentOpen}
/>
) : null;
const taskDocumentSelectionPopover = activeTab === "tasks" && selectedTaskDocument && !editingSelectedTaskDocument && onSendSelectionToTask && activeTaskDocumentSelection ? (
<SelectionCommentPopover
selectedText={activeTaskDocumentSelection.selectedText}
anchorRect={activeTaskDocumentSelection.anchorRect}
filePath={`${selectedTaskDocument.taskId}/${selectedTaskDocument.key}`}
onSubmit={onSendSelectionToTask}
onOpenChange={setSelectionCommentOpen}
/>
) : null;
const searchPlaceholder = activeTab === "project"
? t("documents.searchProjectFiles", "Search project markdown files…")
: activeTab === "tasks"
? t("documents.searchTaskDocuments", "Search task documents…")
: t("documents.searchArtifacts", "Search artifacts…");
return (
<div className="documents-view">
{/*
FNXC:Navigation 2026-06-22-01:10:
Documents/Artifacts adopts the shared ViewHeader (CC-modeled) for a consistent main-content title row; the result count rides in the header actions while the tab bar, hidden-files toggle, and search stay in the controls row below.
FNXC:Navigation 2026-06-21-18:25: FN-6890 keeps the top-level title as Artifacts (renamed from Documents) without changing internal task-document tabs or artifact sub-tabs.
*/}
<div className="documents-view-header">
<ViewHeader
icon={FileText}
title={t("documents.title", "Artifacts")}
actions={(
<span className="documents-view-count">
{t("documents.resultCount", "{{count}} result{{plural}}", { count: activeCount, plural: activeCount !== 1 ? "s" : "" })}
</span>
)}
/>
<div className="documents-controls-row">
<div className="documents-tab-bar" role="tablist" aria-label={t("documents.sectionsLabel", "Documents sections")}>
{/*
FNXC:ArtifactRegistry 2026-07-11-11:30:
Artifacts leads the tab bar (and is the landing tab) — the view is the artifact gallery first; project files and task documents are secondary tabs.
*/}
<button
className={`btn documents-tab${activeTab === "artifacts" ? " active" : ""}`}
role="tab"
aria-selected={activeTab === "artifacts"}
aria-label={t("documents.showArtifacts", "Show artifacts")}
onClick={() => handleTabChange("artifacts")}
>
{t("documents.artifactsTab", "Artifacts")}
<span className="documents-tab-count">{artifacts.length}</span>
</button>
<button
className={`btn documents-tab${activeTab === "project" ? " active" : ""}`}
role="tab"
aria-selected={activeTab === "project"}
aria-label={t("documents.showProjectFiles", "Show project markdown files")}
onClick={() => handleTabChange("project")}
>
{t("documents.projectFilesTab", "Project Files")}
<span className="documents-tab-count">{projectFiles.length}</span>
</button>
<button
className={`btn documents-tab${activeTab === "tasks" ? " active" : ""}`}
role="tab"
aria-selected={activeTab === "tasks"}
aria-label={t("documents.showTaskDocuments", "Show task documents")}
onClick={() => handleTabChange("tasks")}
>
{t("documents.taskDocumentsTab", "Task Documents")}
<span className="documents-tab-count">{groupedTaskItems.length}</span>
</button>
</div>
{activeTab === "project" && (
<button
className="btn btn-sm documents-hidden-toggle"
onClick={() => setShowHiddenProjectFiles((prev) => !prev)}
aria-pressed={showHiddenProjectFiles}
aria-label={showHiddenProjectFiles ? t("documents.hideHidden", "Hide hidden project files") : t("documents.showHidden", "Show hidden project files")}
title={showHiddenProjectFiles ? t("documents.hideHiddenFiles", "Hide hidden files") : t("documents.showHiddenFiles", "Show hidden files")}
>
{showHiddenProjectFiles ? <EyeOff size={14} /> : <Eye size={14} />}
{showHiddenProjectFiles ? t("documents.hideHiddenLabel", "Hide Hidden") : t("documents.showHiddenLabel", "Show Hidden")}
</button>
)}
<div className="documents-search">
<Search size={16} className="documents-search-icon" />
<input
type="text"
className="documents-search-input"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={handleSearchChange}
aria-label={searchPlaceholder}
/>
{searchQuery && (
<button
className="documents-search-clear"
onClick={clearSearch}
aria-label={t("documents.clearSearch", "Clear search")}
>
<X size={16} />
</button>
)}
</div>
</div>
</div>
<div className="documents-view-content">
{activeError ? (
<div className="documents-view-error">
<p>{t("documents.failedToLoad", "Failed to load {{type}}: {{error}}", { type: activeTab === "project" ? t("documents.projectFiles", "project files") : activeTab === "tasks" ? t("documents.taskDocuments", "task documents") : t("documents.artifacts", "artifacts"), error: activeError })}</p>
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label={t("documents.retryLoading", "Retry loading documents")}>
<RefreshCw size={16} />
{t("documents.retry", "Retry")}
</button>
</div>
) : activeTab === "project" ? (
projectFilesLoading && projectFiles.length === 0 ? (
<div className="documents-view-loading">
<p><LoadingSpinner label={t("documents.loadingProjectFiles", "Loading project markdown files…")} /></p>
</div>
) : filteredProjectFiles.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>{t("documents.noMatchProject", "No project markdown files match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>{t("documents.noMarkdownFiles", "No Markdown files found in this project.")}</p>
</>
)}
</div>
) : (
<div className={`documents-project-layout${isMobile ? " documents-project-layout--mobile" : ""}`}>
{(!isMobile || !selectedFile) && (
<aside className="documents-view-sidebar" aria-label={t("documents.projectMarkdownFilesLabel", "Project markdown files")}>
<ul className="markdown-file-list">
{filteredProjectFiles.map((file) => {
const isSelected = selectedFile?.path === file.path;
return (
<li key={file.path} className="markdown-file-list-item">
<button
className={`markdown-file-item${isSelected ? " markdown-file-item--selected" : ""}`}
onClick={() => void handleSelectProjectFile(file)}
aria-label={`Open ${file.path}`}
aria-current={isSelected ? "true" : undefined}
>
<span className="markdown-file-item-name">{file.name}</span>
<span className="markdown-file-item-path">{file.path}</span>
<span className="markdown-file-item-meta">
{formatFileSize(file.size)} · {formatTimestamp(file.mtime)}
</span>
</button>
</li>
);
})}
</ul>
</aside>
)}
{(!isMobile || selectedFile) && (
<section className="documents-view-main" aria-label={t("documents.projectFilePreviewLabel", "Project file content preview")}>
{isMobile && selectedFile && (
<button
className="btn btn-sm documents-mobile-back"
onClick={handleBackToFileList}
aria-label={t("documents.backToFilesList", "Back to project files list")}
>
<ArrowLeft size={14} />
{t("documents.backToFiles", "Back to files")}
</button>
)}
{!selectedFile ? (
<div className="documents-view-empty">
<p>{t("documents.selectFile", "Select a Markdown file to view its content.")}</p>
</div>
) : (
<div className="documents-content-viewer">
<div className="documents-content-header">
<p className="documents-file-path-header">{selectedFile.path}</p>
{/*
FNXC:DocumentsView 2026-07-11-14:45:
Operator requirement: Project Files are editable in place with the shared CodeMirror FileEditor, replacing the former Read-only badge contract (the FN-7810-era badge said "editing happens elsewhere"; it now happens here). While editing, the Markdown/Plain toggle is replaced by Cancel/Save and select-to-comment is suppressed.
*/}
<div className="documents-task-document-actions">
{editingProjectFile ? (
<>
<button className="btn btn-sm" onClick={handleCancelProjectFileEdit} disabled={projectFileSaving}>
{t("documents.cancelEdit", "Cancel")}
</button>
<button className="btn btn-sm btn-primary" onClick={() => void handleSaveProjectFileEdit()} disabled={projectFileSaving}>
{projectFileSaving ? t("documents.saving", "Saving…") : t("documents.saveProjectFile", "Save")}
</button>
</>
) : (
<>
<button
className="btn btn-sm document-mode-toggle"
onClick={() => setRenderProjectMarkdown((prev) => !prev)}
aria-label={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
aria-pressed={renderProjectMarkdown}
title={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
>
{renderProjectMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
</button>
<button
className="btn btn-sm"
onClick={handleStartProjectFileEdit}
disabled={fileLoading || fileError !== null || fileContent === null}
aria-label={t("documents.editProjectFile", "Edit project file")}
>
<Pencil size={14} aria-hidden="true" />
{t("documents.edit", "Edit")}
</button>
</>
)}
</div>
</div>
{fileLoading ? (
<p className="documents-content-state"><LoadingSpinner label={t("documents.loadingFileContent", "Loading file content…")} /></p>
) : fileError ? (
<p className="documents-content-state documents-content-state--error">{fileError}</p>
) : editingProjectFile ? (
<div className="documents-task-document-editor" aria-label={t("documents.projectFileContentEditor", "Project file content editor")}>
<FileEditor
content={projectFileDraft}
onChange={setProjectFileDraft}
filePath={selectedFile.path}
forceToolbarActionsVisible
/>
</div>
) : renderProjectMarkdown ? (
<div ref={markdownPreviewRef} className="documents-content-markdown">
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{fileContent ?? ""}</ReactMarkdown>
</div>
</div>
) : (
<pre ref={plainPreviewRef} className="document-card-content-text documents-content-viewer-text">{fileContent ?? ""}</pre>
)}
{selectionPopover}
</div>
)}
</section>
)}
</div>
)
) : activeTab === "artifacts" ? (
artifactsLoading && artifacts.length === 0 ? (
<div className="documents-view-loading">
<p>{t("documents.loadingArtifacts", "Loading artifacts…")}</p>
</div>
) : artifacts.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>{t("documents.noMatchArtifacts", "No artifacts match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>{t("documents.noArtifacts", "No artifacts yet.")}</p>
<p className="documents-view-empty-hint">
{t("documents.artifactsCreatedBy", "Agents register screenshots, wireframes, mockups, recordings, and documents here as they work on tasks.")}
</p>
</>
)}
</div>
) : (
/*
FNXC:ArtifactRegistry 2026-07-10-15:40:
The Artifacts tab delegates to ArtifactsGallery: a category-driven surface (Images, Docs, PDFs, Videos, Audio, Other) with a tailored viewer per category, including an editable full document viewer for inline-content docs.
*/
<ArtifactsGallery
artifacts={artifacts}
projectId={projectId}
isMobile={isMobile}
addToast={addToast}
onOpenTask={handleOpenArtifactTask}
onArtifactUpdated={() => void refreshArtifacts()}
/>
)
) : documentsLoading && documents.length === 0 ? (
<div className="documents-view-loading">
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
</div>
) : groupedTaskItems.length === 0 ? (
<div className="documents-view-empty">
{searchQuery.trim() ? (
<p>{t("documents.noMatchTask", "No task documents match \"{{query}}\".", { query: searchQuery.trim() })}</p>
) : (
<>
<FileText size={48} className="documents-view-empty-icon" />
<p>{t("documents.noTaskDocuments", "No task documents yet.")}</p>
<p className="documents-view-empty-hint">
{t("documents.documentsCreatedIn", "Documents are created in task detail tabs.")}
</p>
</>
)}
</div>
) : (
/*
FNXC:DocumentsView 2026-07-10-17:30:
FN-7811 requires Task Documents to use the same desktop two-pane and mobile list→detail→back gating as Project Files; 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" : ""}`}>
{(!isMobile || !selectedTaskOutput) && (
<aside className="documents-view-sidebar documents-task-documents-sidebar" aria-label={t("documents.taskDocumentsListLabel", "Task documents")}>
{groupedTaskItems.map(({ taskId, taskTitle, taskColumn, documents: taskDocs, artifacts: taskArtifacts }) => {
const taskStatusLabel = taskColumn ? columnLabel(taskColumn as ColumnId) : null;
const taskStatusDotClass = taskColumn
? getTaskColumnStatusDotClass(taskColumn, columnFlagsByTaskId?.get(taskId))
: "status-dot";
return (
<section key={taskId} className="documents-task-sidebar-group" aria-labelledby={`documents-task-group-${taskId}`}>
<div className="documents-task-sidebar-group-header">
<div className="documents-task-sidebar-title-wrap">
<h3 id={`documents-task-group-${taskId}`} className="documents-task-sidebar-title">
<span className="documents-group-task-id">{taskId}</span>
<span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span>
</h3>
{taskStatusLabel ? (
<span className="documents-group-status badge" aria-label={t("documents.taskStatusAria", "Task status: {{status}}", { status: taskStatusLabel })}>
<span className={taskStatusDotClass} aria-hidden="true" />
<span>{taskStatusLabel}</span>
</span>
) : null}
</div>
<div className="documents-task-sidebar-actions">
<span className="documents-group-count">{t("documents.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
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={`document-${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>
);
})}
{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>
</section>
);
})}
</aside>
)}
{(!isMobile || selectedTaskOutput) && (
<section className="documents-view-main" aria-label={t("documents.taskDocumentPreviewLabel", "Task document content preview")}>
{isMobile && selectedTaskOutput && (
<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>
)}
{!selectedTaskOutput ? (
<div className="documents-view-empty">
<p>{t("documents.selectTaskDocument", "Select a task document or artifact to view its content.")}</p>
</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">
{/*
FNXC:DocumentsView 2026-07-11-14:30:
The header actions (Plain/Edit, or Cancel/Save while editing) must sit on the same row as the document path box, vertically centered against it — when the path and the author/revision meta shared a title-block column, the actions centered on the two-row block and rendered visibly below the path box's midline (user-reported misalignment). The meta line now renders below the path+actions row at full width.
*/}
<div className="documents-content-header documents-task-document-header">
<p className="documents-file-path-header">{selectedTaskDocument.taskId} / {selectedTaskDocument.key}</p>
{/*
FNXC:DocumentsView 2026-07-11-13:40:
Operator requirement: task documents must be editable in place from the Artifacts view with the shared CodeMirror FileEditor (not a plain textarea, and not read-only). While editing, the Markdown/Plain toggle is replaced by Cancel/Save (FileEditor carries its own Edit/Preview toolbar for markdown) and select-to-comment is suppressed so the composer lock cannot fight the editor selection.
*/}
<div className="documents-task-document-actions">
{editingSelectedTaskDocument ? (
<>
<button className="btn btn-sm" onClick={handleCancelTaskDocEdit} disabled={taskDocSaving}>
{t("documents.cancelEdit", "Cancel")}
</button>
{pendingTaskDocRebase && (
<button className="btn btn-sm" onClick={handleRebaseTaskDocEdit} disabled={taskDocSaving}>
{t("documents.rebaseTaskDocument", "Rebase draft")}
</button>
)}
<button className="btn btn-sm btn-primary" onClick={() => void handleSaveTaskDocEdit()} disabled={taskDocSaving || pendingTaskDocRebase !== null}>
{taskDocSaving ? t("documents.saving", "Saving…") : t("documents.saveTaskDocument", "Save")}
</button>
</>
) : (
<>
<button
className="btn btn-sm document-mode-toggle"
onClick={() => handleToggleTaskDocMarkdown(selectedTaskDocument.id)}
aria-label={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
aria-pressed={selectedTaskDocumentRendersMarkdown}
title={selectedTaskDocumentRendersMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
>
{selectedTaskDocumentRendersMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
</button>
<button className="btn btn-sm" onClick={handleStartTaskDocEdit} aria-label={t("documents.editTaskDocument", "Edit task document")}>
<Pencil size={14} aria-hidden="true" />
{t("documents.edit", "Edit")}
</button>
</>
)}
</div>
</div>
<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>
{editingSelectedTaskDocument ? (
<div className="documents-task-document-editor" aria-label={t("documents.taskDocumentContentEditor", "Task document content editor")}>
<FileEditor
content={taskDocDraft}
onChange={setTaskDocDraft}
filePath={selectedTaskDocument.key.includes(".") ? selectedTaskDocument.key : `${selectedTaskDocument.key}.md`}
forceToolbarActionsVisible
/>
</div>
) : selectedTaskDocumentRendersMarkdown ? (
<div ref={taskDocMarkdownPreviewRef} className="documents-content-markdown">
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedTaskDocument.content}</ReactMarkdown>
</div>
</div>
) : (
<pre ref={taskDocPlainPreviewRef} className="document-card-content-text documents-content-viewer-text">{selectedTaskDocument.content}</pre>
)}
{taskDocumentSelectionPopover}
</div>
) : null}
</section>
)}
</div>
)}
</div>
</div>
);
}