**Batch branch is live: `batch-dashboard-app`.** Push conversions here as commits rather than opening per-file PRs — that is the CI-run bottleneck this model removes. **One-line ownership note for you to arbitrate:** you have addressed me as U11, U12 and U7 at different points, so the `u12 worker -> batch-dashboard-app` mapping is ambiguous from my side. I claimed it because `dashboard/app` is where I have done the most work this session (TaskContextMenu, Column, TaskCard, TaskDetailModal, columnRoles, taskActivity) and I know which of its guards are load-bearing fallbacks. **If another worker is the intended owner, say so and I will hand the branch over rather than both of us pushing to it** — two workers on one shared branch is exactly what silently discarded a reviewed fix in #2645 today. ## The work order (measured at branch point, tests excluded) **75 guards across 32 files.** Largest: `TaskContextMenu.tsx` 9 · `Column.tsx` 7 · `ListView.tsx` 6 · `TaskDetailModal.tsx` 4 · then a long tail of 3s, 2s and 1s. Full per-file list is in the committed work order so feeders can claim without re-measuring. ## Two rules this surface keeps tripping on **1. A literal after `??`, or in the `else` of a `flags ?` ternary, is a DEGRADED-MODE answer — not an unconverted guard.** Two real states reach it: the **pre-load window** (board renders before the workflows fetch resolves) and a card stranded on an id its workflow no longer declares. In both, `columnFlagsById` has no entry at all. Deleting the fallback does not remove a decision — it substitutes "no role" silently, and affordances vanish during first paint. Those sites reach 0 by **marking**, not deleting. Expect `TaskContextMenu.tsx` and the `utils` files to be **mostly marks**. A "9 → 0" that deleted 9 fallbacks is a regression wearing a green census. **2. A marker excuses ONLY the construct it is attached to** — the statement or function holding the literal, not a sibling declaration. This has cost three passes, two of them mine; my first attempt on `reliability-metrics.ts` scored **1 of 6**. **Verify by the count moving, not by the comment existing.** With the ratchet gate-blocking, a mis-marked batch either wedges the gate or locks the miss into a re-recorded baseline. ## Status Opening commit is the work order only — **0 of 75 converted so far.** I am near the end of my context, so I am establishing the branch and the shared list rather than starting conversions I cannot finish cleanly. Feeders can begin immediately; I will keep the branch rebased. My other PR **#2762** (`live-agent-count.ts` 6 → 0) is green and unconflicted — per your rule it should land rather than fold into a batch, and it is `packages/core` so it belongs to batch-core anyway. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Task UI now resolves workflow “column roles” per task to drive diffs/merge details, routing/steering, progress/runtime visibility, and review badges. * Right-dock/overflow views and dev-server now use per-task column traits for “executing” behavior and dependency-based “Up Next” eligibility. * **Bug Fixes** * Fixed bulk action selection/delete/archive eligibility and prevented cross-workflow role leakage. * Made in-review/stale-paused-review, stuck, and effective executor/validator model logic role-aware. * **Tests** * Added regression coverage for degraded-flag behavior and ensured resolved-flag props aren’t ignored. * Added a static check to fail builds on inert optional flag seams. * **Documentation** * Updated batch work-order and mega-batch branch guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ## Late addition: the seam gate was masking a real offender `scripts/check-inert-flag-seams.mjs` matched call sites by NAME, so two same-named functions in different modules were conflated. I had documented that as a known false-positive source and moved on — reports mentioning `sortTasksForDisplayColumn` are noise, read past them. That annotation was the damage. Core's `sortTasksForDisplayColumn` genuinely never receives its `columnFlags` argument outside its own tests. The dashboard's separate function of the same name (`app/components/taskSorting.ts`), called with up to five arguments from `Lane`/`Board`/`ListView`, was raising the arg-count max and clearing core's seam. The offender was behind a row everyone had been told to skip. The gate now records the module each callee is imported from and matches it against the seam's declaring module. **Measured, by reverting the change:** the scan prints `17 seams, all supplied` and emits **no row** for the function. With the change, it is reported. Both directions watched. Reported on #2783 rather than fixed from outside — core owns it, and "wire the flags" vs "drop the parameter and let the literal stay counted" is their judgment call. TEMPORARY allow-list entry carries it meanwhile; the existing staleness check fails the moment the site becomes supplied, so the entry cannot outlive the fix. Two known limits remain, both inherent to name matching and both documented in the script: the one-supplier floor, and the `__tests__` exclusion (hence the two permanent `ALLOWED` entries). ## And the one-supplier floor, closed the same way I wrote in the section above that the floor "hasn't cost anything yet." That is verbatim the reasoning that kept the imported-shadow bug alive, so I closed it instead of leaving the note. `best < arity` asked only whether SOME caller supplied the argument. One correct call site cleared the seam while every sibling took the legacy fallback — the `isTaskStuck` defect class, where two of three sites omitted the flags and the gate stayed green because the third was right. Review caught that one. A partially-supplied seam is the harder of the two: wholly-unsupplied is uniformly wrong, this works on the board you tested and degrades on the column you did not. **Measured:** dropping the flags argument at `Column.tsx`'s supplied call site produces `supplied by 5/6 call sites; omitted at packages/dashboard/app/components/Column.tsx:1 (of 2)`; restoring returns `all supplied at every call site`. Red and green both watched. Two real omissions found, both on `isNearDuplicateCanonicalInactive`: - **`TaskDetailModal.tsx`** — deliberate, and it **corrects a note I left at that site**. The old note said hoisting the flags state was "the actual fix." It is not, for this call: the flags in scope describe the *modal's* task, and the canonical is a **different task** on a column this component never resolves. Passing them would type-check, read as a conversion, and answer about the wrong task — exactly what `column-role-degraded-flags.test.ts` exists to catch. Supplying it correctly needs a fetch, which is a data change and out of scope. - **`core/task-store/branch-group-ops.ts`** — genuinely wireable (the impl is async and already holds `store` and `canonicalId`). Reported on #2783, not edited from outside. Exemptions for this class are keyed by **call site** (`<file>::<function>`), not by function name. A name-level entry would waive every site of a partially-supplied seam, which is backwards — its other sites are correct and are the reason the omission is worth reporting. Both entries carry the same staleness check as the name-level list and cannot outlive their fix. Remaining known limit, now the only one: the `__tests__` exclusion, which makes a test-only export read as having no callers. That is what the two permanent `ALLOWED` entries are. ## The `__tests__` exclusion, and two allow-list entries built on false reasons Named as the "last remaining limit" above, so it got closed too. The scan now reads test files for call sites — but counts them **separately**, and a test never clears a seam. That direction is the dangerous one: counting test callers as suppliers would have re-hidden core's `sortTasksForDisplayColumn`, whose only suppliers are its own tests. Measured by lifting its exemption: still reported. Both permanent allow-list entries claimed the scanner couldn't see their callers. **Both reasons were false**, and reading tests is what proved it: - **`evaluateMergeBlockerGuard`** — zero callers in tests either. Its only reference in the repo is its own declaration; never registered as a trait hook; the `evaluateDefaultWorkflowGuards` reader its file header credits does not exist. The `lifecycleColumns` conversion went onto dead code, and its note describes a crossing the guard cannot make. Reported on #2783, including the two things I am explicitly *not* concluding (no `"guard"` hook is registered in production; whether that is residue or a dropped registration needs core's intent). - **`isRecoverableMissingWorktreeReviewFailure`** — 5 test call sites. It wraps `...WithProgress`/`...NoProgress`, the live pair called from `self-healing.ts`, both supplying `reviewColumns`. Entry kept, true reason recorded. ### A wrong turn, recorded because it is the failure mode this PR is about I first classified no-production-caller seams as *informational* when they weren't re-exported from a package index, reasoning that a public export might be called externally. That silently downgraded `sortTasksForDisplayColumn` — a confirmed real offender — from failing to a footnote. Publication status has nothing to do with whether there is production behaviour to be wrong. Reverted to the simple rule: no production caller means inert, and it fails. It is worth stating plainly because it is the exact shape of everything else in this PR: a change that made the gate read *cleaner* while making it catch *less*, and it type-checked, passed every test, and would have reviewed fine. ### Where that leaves the check Every blind spot named in this PR has now been closed, and **each one produced a real defect within minutes of closing it** — imported shadows, the one-supplier floor, the `__tests__` exclusion. Four verified findings went to core, one to engine. I would not read the remaining ~240 guards' green gates as evidence that they are clean; I would read them as untested. ## Two guards for one question, one of them worse Having hardened the script, I checked its older twin rather than assuming it was fine. `resolved-flags-seams-have-suppliers.test.ts` carried its own copy of the trailing-flags-parameter check — written before the script existed — with **all three** holes the script has since closed. **Measured on one reintroduced defect** (dropping the flags argument at `Column.tsx`'s supplied `isNearDuplicateCanonicalInactive` call): | | result | |---|---| | `scripts/check-inert-flag-seams.mjs` | `supplied by 5/6 call sites; omitted at .../Column.tsx:1 (of 2)` | | this test's arity half | **3 passed** | Deleted the arity half. Redundancy between a strong and a weak check isn't redundancy — it's a green result available to whoever runs the weak one, and there was no signal at the call site telling you which you were looking at. The **props-shape half stays**: it has no twin in the script, and I confirmed it still fires by reintroducing the original `PrPanel` defect (outer component stops destructuring `taskColumnFlags`) — it reports `PrPanel declares taskColumnFlags but never takes it`. Dashboard app suite: **113 files / 3921 tests** (was 3922 — the deleted case is the difference). ## The gate started catching defects as they landed Syncing with main brought in three fresh conversions from other workers. The hardened check flagged all three immediately — the first time these guards have fired on someone else's landed code rather than on my own. - **`TaskCard`** — `getRunningOptionalGateBadge(task)` omitted flags while *both* `ListView` sites supplied. Fixed, and `taskColumnFlags` added to the `useMemo` deps: no `exhaustive-deps` rule here, so a memo that reads flags without listing them keeps the first-paint `undefined` answer and reproduces the bug through staleness instead of omission. - **`TaskTokenStatsPanel`** — `getTotalAgentActiveMs` omitted while `TaskCard` supplied, so the same runtime number came from the real column on a card and from legacy ids in the detail modal. Now takes `columnFlags`, supplied from `detailColumnFlags` — correct here because the panel renders the modal's **own** task, unlike the near-duplicate canonical above. - **`ListView` ×2** — passed `columnFlagsById.get(task.column)`, the cross-workflow **union**. A task whose own workflow doesn't declare that column gets a *neighbour workflow's* traits. The landed comment justified it as "this list already owns `columnFlagsById`" — exactly the reasoning `column-role-degraded-flags.test.ts` exists to reject. It failed on merge and is how I found this. Also: the `getTotalAgentActiveMs` exemption I was carrying **self-retired**. Main wired the seam, the staleness check failed the entry, and I removed it. That mechanism has now paid for itself once. ### Pre-existing, NOT from this PR: `App.test.tsx` is red on main `app/components/__tests__/App.test.tsx` fails **10 of 141** identically with my changes, with my changes stashed, and with main's own `App.tsx` restored. Not mine, and not in the merge gate. **Bisected on clean `main` checkouts, so this is measured rather than inferred:** | commit | date | result | |---|---|---| | `main~400` (`41d60f0355`) | 2026-07-25 | **140 passed** (140 tests) | | `main~275` (`74d6513fae`) | 2026-07-27 | 3 failed / 141 | | `main~210` (`d2ce1ba8b5`) | 2026-07-29 | 10 failed / 141 | | `main` (`6fc98fd6c7`) | 2026-07-30 | 10 failed / 141 | So it is **not one regression** — it degraded in two stages across 2026-07-25 → 07-29, and the test file itself changed in that window (140 → 141 tests). Three commits touched it there: `73b2a32e2b`, `f26cbedf4f`, `f157bf7460`. That window overlaps the workflow-owned lifecycle migration, which is suggestive but not something I confirmed. The failures are render-level, not assertion-level — `Unable to find an element with the text: + New Task`, `Unable to find role="dialog"`, `Unable to find ... Back nav task`. The board appears to render nothing. That reads like a real regression or a harness mismatch after the lifecycle migration, not a flake, so I have deliberately **not** quarantined it — quarantine is for flakes, and using it here would hide the signal. Flagging for whoever owns `App.tsx`. My suites: `app/__tests__` **113 files / 3921 tests** green, `tsc` 0, lint 0, census `--strict` 0, seam gate 0. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1290 lines
65 KiB
TypeScript
1290 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";
|
|
}
|
|
/* DELIBERATE-LITERAL — the no-metadata fallback. The resolved-trait branch is immediately above
|
|
(`if (flags) { ... }`); these lines answer only when a task has no flags at all, and deleting
|
|
them makes every such row render the neutral "connecting" dot. */
|
|
if (taskColumn === "done") return "status-dot status-dot--online";
|
|
/* DELIBERATE-LITERAL — same no-metadata fallback as the line above. */
|
|
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>
|
|
);
|
|
}
|