FN-6779: add artifacts gallery to Documents

Adds a Documents-view artifact gallery backed by dashboard artifact registry APIs.

- Add artifact listing and media-serving endpoints with project scoping, validation, and safe artifact path resolution.
- Add client artifact API helpers, SWR caching, and a useArtifacts hook for background-refresh gallery data.
- Extend Documents view with an Artifacts tab, media previews, localized labels, documentation, tests, and a published package changeset.

Files changed:
 .changeset/fn-6779-artifacts-gallery.md            |   5 +
 docs/dashboard-guide.md                            |   6 +-
 packages/dashboard/app/api/legacy.ts               |  34 ++++
 .../dashboard/app/components/DocumentsView.css     | 142 ++++++++++++++++
 .../dashboard/app/components/DocumentsView.tsx     | 178 ++++++++++++++++++--
 .../components/__tests__/DocumentsView.test.tsx    | 163 +++++++++++++++++-
 .../app/hooks/__tests__/useArtifacts.test.ts       | 136 +++++++++++++++
 packages/dashboard/app/hooks/useArtifacts.ts       | 145 ++++++++++++++++
 packages/dashboard/app/utils/swrCache.ts           |   1 +
 .../src/routes/__tests__/artifacts-routes.test.ts  | 182 +++++++++++++++++++++
 .../src/routes/register-task-workflow-routes.ts    | 125 ++++++++++++++
 packages/i18n/locales/en/app.json                  |  22 ++-
 packages/i18n/locales/es/app.json                  |  27 ++-
 packages/i18n/locales/fr/app.json                  |  27 ++-
 packages/i18n/locales/ko/app.json                  |  27 ++-
 packages/i18n/locales/zh-CN/app.json               |  27 ++-
 packages/i18n/locales/zh-TW/app.json               |  27 ++-
 scripts/line-count-baseline.json                   |  16 +-
 18 files changed, 1255 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-6779

Fusion-Task-Lineage: 1d71e4be-4bfc-4706-9cbf-7214f91a79d1
This commit is contained in:
gsxdsm
2026-06-21 16:01:07 -07:00
parent 7ee1fec22c
commit ef4889585f
18 changed files with 1255 additions and 35 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add dashboard artifact registry read APIs, client helpers, and a Documents-view Artifacts media gallery for images, videos, audio, documents, and generic artifacts.

View File

@@ -471,14 +471,16 @@ For per-run aggregation, `GET /api/agents/:id/runs/:runId/cited-goals` returns `
## Documents View
Documents view aggregates task documents and project markdown files.
Documents view aggregates task documents, project markdown files, and registered artifacts.
Features:
- Group task documents by task ID (with revision history metadata)
- Search documents across tasks
- Open project markdown files with inline preview
- Jump directly from a document group to the owning task detail modal
- Browse the **Artifacts** tab for media registered by agents, users, or the system across tasks
- Preview artifact images inline, play video and audio with native controls, read document previews, and open generic artifacts through their media URL
- Jump directly from a document group or artifact card to the owning task detail modal when a task is linked
- Toggle between raw text and rendered markdown using the **Markdown/Plain** button
- Highlight text in raw or rendered project-file previews, choose **Add comment**, and send the file path, selected snippet, and your comment to the **New Task** dialog

View File

@@ -27,6 +27,9 @@ import type {
TaskDocument,
TaskDocumentRevision,
TaskDocumentWithTask,
Artifact,
ArtifactType,
ArtifactWithTask,
Message,
MessageMetadata,
@@ -1474,6 +1477,37 @@ export interface MarkdownFileListResponse {
files: MarkdownFileEntry[];
}
export type { Artifact, ArtifactType, ArtifactWithTask };
export interface FetchArtifactsOptions {
type?: ArtifactType;
authorId?: string;
taskId?: string;
q?: string;
limit?: number;
offset?: number;
}
export async function fetchArtifacts(
options?: FetchArtifactsOptions,
projectId?: string,
): Promise<ArtifactWithTask[]> {
const params = new URLSearchParams();
if (options?.type) params.set("type", options.type);
if (options?.authorId) params.set("authorId", options.authorId);
if (options?.taskId) params.set("taskId", options.taskId);
if (options?.q) params.set("q", options.q);
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.offset !== undefined) params.set("offset", String(options.offset));
const queryString = params.toString();
const path = `/artifacts${queryString ? `?${queryString}` : ""}`;
return api<ArtifactWithTask[]>(withProjectId(path, projectId));
}
export function artifactMediaUrl(id: string, projectId?: string): string {
return buildApiUrl(withProjectId(`/artifacts/${encodeURIComponent(id)}/media`, projectId));
}
export async function fetchAllDocuments(
options?: FetchAllDocumentsOptions,
projectId?: string,

View File

@@ -617,6 +617,126 @@
font-family: var(--font-primary);
}
.documents-artifact-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: var(--space-md);
align-items: start;
}
.documents-artifact-card {
display: flex;
flex-direction: column;
min-height: 100%;
}
.documents-artifact-preview {
display: flex;
align-items: center;
justify-content: center;
min-height: 12rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.documents-artifact-media {
display: block;
width: 100%;
max-height: 18rem;
object-fit: contain;
background: var(--bg);
}
.documents-artifact-audio {
width: calc(100% - var(--space-lg));
}
.documents-artifact-document,
.documents-artifact-generic {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
min-height: 12rem;
padding: var(--space-lg);
color: var(--text-muted);
text-align: center;
}
.documents-artifact-document p {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
word-break: break-word;
}
.documents-artifact-generic {
text-decoration: none;
transition: color var(--transition-fast), background var(--transition-fast);
}
.documents-artifact-generic:hover {
color: var(--todo);
background: var(--card-hover);
}
.documents-artifact-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
}
.documents-artifact-header,
.documents-artifact-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
color: var(--text-dim);
font-size: 0.75rem;
}
.documents-artifact-type-badge {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: var(--space-xs) var(--space-sm);
color: var(--todo);
background: color-mix(in srgb, var(--todo) 10%, transparent);
font-size: 0.75rem;
font-weight: 600;
}
.documents-artifact-author {
font-family: var(--font-mono);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.documents-artifact-title {
margin: 0;
color: var(--text);
font-size: 1rem;
line-height: 1.3;
}
.documents-artifact-description {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
word-break: break-word;
}
.documents-artifact-task-link {
align-self: flex-start;
}
@media (max-width: 768px) {
/* Documents View mobile */
@@ -727,6 +847,28 @@
font-size: 12px;
}
.documents-artifact-gallery,
.documents-artifact-gallery--mobile {
grid-template-columns: 1fr;
}
.documents-artifact-preview,
.documents-artifact-document,
.documents-artifact-generic {
min-height: 10rem;
}
.documents-artifact-meta,
.documents-artifact-header {
align-items: flex-start;
flex-direction: column;
}
.documents-artifact-task-link {
width: 100%;
min-height: calc(var(--space-xl) + var(--space-sm));
}
/* Document mode toggle: ensure adequate touch target on mobile */
.document-mode-toggle {
min-width: 36px;

View File

@@ -1,12 +1,13 @@
import "./DocumentsView.css";
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff, Package } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ArtifactType, ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
import { artifactMediaUrl, fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
import { useArtifacts } from "../hooks/useArtifacts";
import { useDocuments } from "../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
import { useSelectionComment } from "../hooks/useSelectionComment";
@@ -15,7 +16,7 @@ import { LoadingSpinner } from "./LoadingSpinner";
const MOBILE_BREAKPOINT = 768;
type DocumentsTab = "project" | "tasks";
type DocumentsTab = "project" | "tasks" | "artifacts";
export interface DocumentsViewProps {
projectId?: string;
@@ -39,6 +40,12 @@ interface TaskGroupProps {
onToggleMarkdown: (docId: string) => void;
}
interface ArtifactCardProps {
artifact: ArtifactWithTask;
projectId?: string;
onOpenTask: (taskId: string) => void;
}
function formatTimestamp(iso?: string): string {
if (!iso) return "";
return new Date(iso).toLocaleString();
@@ -154,7 +161,7 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
<button
className="documents-group-task-link"
onClick={() => onOpenTask(taskId)}
aria-label={`Open task ${taskId}: ${taskTitle || t("documents.untitled", "Untitled")}`}
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId, title: taskTitle || t("documents.untitled", "Untitled") })}
>
{t("documents.openTask", "Open task")}
</button>
@@ -176,6 +183,83 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
);
}
function getArtifactTypeLabel(t: ReturnType<typeof useTranslation<"app">>["t"], type: ArtifactType): string {
switch (type) {
case "image":
return t("documents.artifactTypeImage", "Image");
case "video":
return t("documents.artifactTypeVideo", "Video");
case "audio":
return t("documents.artifactTypeAudio", "Audio");
case "document":
return t("documents.artifactTypeDocument", "Document");
case "other":
return t("documents.artifactTypeOther", "Other");
}
}
function ArtifactCard({ artifact, projectId, onOpenTask }: ArtifactCardProps) {
const { t } = useTranslation("app");
const mediaUrl = artifactMediaUrl(artifact.id, projectId);
const typeLabel = getArtifactTypeLabel(t, artifact.type);
const preview = artifact.content ? getContentPreview(artifact.content, 320) : artifact.description;
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
const media = (() => {
switch (artifact.type) {
case "image":
return <img className="documents-artifact-media" src={mediaUrl} alt={title} loading="lazy" />;
case "video":
return <video className="documents-artifact-media" controls src={mediaUrl} aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />;
case "audio":
return <audio className="documents-artifact-audio" controls src={mediaUrl} aria-label={t("documents.artifactAudioLabel", "Audio artifact: {{title}}", { title })} />;
case "document":
return (
<div className="documents-artifact-document" data-testid="artifact-document-preview">
<FileText size={16} />
<p>{preview || t("documents.noArtifactPreview", "No preview available.")}</p>
</div>
);
case "other":
return (
<a className="documents-artifact-generic" href={mediaUrl} target="_blank" rel="noreferrer" data-testid="artifact-other-link">
<Package size={16} />
{t("documents.openArtifactMedia", "Open artifact media")}
</a>
);
}
})();
return (
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
<div className="documents-artifact-preview">
{media}
</div>
<div className="documents-artifact-body">
<div className="documents-artifact-header">
<span className="documents-artifact-type-badge">{typeLabel}</span>
<span className="documents-artifact-author">{artifact.authorId}</span>
</div>
<h3 className="documents-artifact-title">{title}</h3>
{artifact.description && <p className="documents-artifact-description">{artifact.description}</p>}
<div className="documents-artifact-meta">
<span>{formatTimestamp(artifact.createdAt)}</span>
{artifact.sizeBytes !== undefined && <span>{formatFileSize(artifact.sizeBytes)}</span>}
</div>
{artifact.taskId && (
<button
className="documents-group-task-link documents-artifact-task-link"
onClick={() => onOpenTask(artifact.taskId as string)}
aria-label={t("documents.openTaskAria", "Open task {{taskId}}: {{title}}", { taskId: artifact.taskId, title: artifact.taskTitle || t("documents.untitled", "Untitled") })}
>
{t("documents.openTask", "Open task")}
</button>
)}
</div>
</article>
);
}
export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelectionToTask }: DocumentsViewProps) {
const { t } = useTranslation("app");
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
@@ -200,6 +284,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
const artifactSearchQuery = activeTab === "artifacts" ? searchQuery.trim() : "";
const {
documents,
@@ -219,6 +304,16 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
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);
@@ -245,7 +340,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
}, [projectId]);
useEffect(() => {
if (initialTabSetRef.current || documentsLoading || projectFilesLoading) {
if (initialTabSetRef.current || documentsLoading || projectFilesLoading || artifactsLoading) {
return;
}
@@ -253,10 +348,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
setActiveTab("project");
} else if (documents.length > 0) {
setActiveTab("tasks");
} else if (artifacts.length > 0) {
setActiveTab("artifacts");
}
initialTabSetRef.current = true;
}, [documents.length, documentsLoading, projectFiles.length, projectFilesLoading]);
}, [artifacts.length, artifactsLoading, documents.length, documentsLoading, projectFiles.length, projectFilesLoading]);
const groupedDocuments = useMemo(() => {
const groups = new Map<string, TaskDocumentWithTask[]>();
@@ -373,17 +470,21 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
});
}, []);
const activeError = activeTab === "project" ? projectFilesError : documentsError;
const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
const handleRetry = useCallback(async () => {
if (activeTab === "project") {
await refreshProjectFiles();
return;
}
await refreshDocuments();
}, [activeTab, refreshProjectFiles, refreshDocuments]);
if (activeTab === "tasks") {
await refreshDocuments();
return;
}
await refreshArtifacts();
}, [activeTab, refreshArtifacts, refreshProjectFiles, refreshDocuments]);
const activeCount = activeTab === "project" ? filteredProjectFiles.length : documents.length;
const activeCount = activeTab === "project" ? filteredProjectFiles.length : activeTab === "tasks" ? documents.length : artifacts.length;
const selectionPopover = selectedFile && onSendSelectionToTask && activeProjectSelection ? (
<SelectionCommentPopover
selectedText={activeProjectSelection.selectedText}
@@ -396,7 +497,9 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
const searchPlaceholder = activeTab === "project"
? t("documents.searchProjectFiles", "Search project markdown files…")
: t("documents.searchTaskDocuments", "Search task documents…");
: activeTab === "tasks"
? t("documents.searchTaskDocuments", "Search task documents…")
: t("documents.searchArtifacts", "Search artifacts…");
return (
<div className="documents-view">
@@ -433,6 +536,20 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
{t("documents.taskDocumentsTab", "Task Documents")}
<span className="documents-tab-count">{groupedDocuments.length}</span>
</button>
{/*
FNXC:ArtifactRegistry 2026-06-21-04:46:
The Documents navigation has one canonical Artifacts tab so media produced by any agent is discoverable without adding another dashboard destination.
*/}
<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>
</div>
{activeTab === "project" && (
@@ -474,7 +591,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
<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") : t("documents.taskDocuments", "task documents"), error: activeError })}</p>
<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")}
@@ -575,6 +692,41 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
)}
</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", "Artifacts are created by agents, users, and system tools.")}
</p>
</>
)}
</div>
) : (
/*
FNXC:ArtifactRegistry 2026-06-21-04:46:
The gallery must render all artifact media classes in one responsive surface: images, video, audio, inline documents, and generic file links keep their task and author context visible.
*/
<div className={`documents-artifact-gallery${isMobile ? " documents-artifact-gallery--mobile" : ""}`}>
{artifacts.map((artifact) => (
<ArtifactCard
key={artifact.id}
artifact={artifact}
projectId={projectId}
onOpenTask={handleOpenTask}
/>
))}
</div>
)
) : documentsLoading && documents.length === 0 ? (
<div className="documents-view-loading">
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>

View File

@@ -1,8 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
import { DocumentsView } from "../DocumentsView";
import { fetchTaskDetail, fetchWorkspaceFileContent } from "../../api";
import { useArtifacts } from "../../hooks/useArtifacts";
import { useDocuments } from "../../hooks/useDocuments";
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
@@ -11,17 +12,24 @@ vi.mock("../../api", () => ({
fetchAllDocuments: vi.fn(),
fetchWorkspaceFileContent: vi.fn(),
fetchTaskDetail: vi.fn(),
fetchArtifacts: vi.fn(),
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
}));
vi.mock("../../hooks/useDocuments", () => ({
useDocuments: vi.fn(),
}));
vi.mock("../../hooks/useArtifacts", () => ({
useArtifacts: vi.fn(),
}));
vi.mock("../../hooks/useProjectMarkdownFiles", () => ({
useProjectMarkdownFiles: vi.fn(),
}));
const mockUseDocuments = vi.mocked(useDocuments);
const mockUseArtifacts = vi.mocked(useArtifacts);
const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
@@ -96,6 +104,69 @@ const mockHiddenProjectFile = {
mtime: "2026-04-19T10:00:00.000Z",
};
const mockArtifacts: ArtifactWithTask[] = [
{
id: "artifact-image",
type: "image",
title: "Image artifact",
description: "Rendered image",
mimeType: "image/png",
sizeBytes: 128,
uri: "artifacts/image.png",
authorId: "agent-image",
authorType: "agent",
taskId: "KB-001",
taskTitle: "Alpha task",
createdAt: "2026-04-19T12:00:00.000Z",
updatedAt: "2026-04-19T12:00:00.000Z",
},
{
id: "artifact-video",
type: "video",
title: "Video artifact",
mimeType: "video/mp4",
uri: "artifacts/video.mp4",
authorId: "agent-video",
authorType: "agent",
createdAt: "2026-04-19T11:00:00.000Z",
updatedAt: "2026-04-19T11:00:00.000Z",
},
{
id: "artifact-audio",
type: "audio",
title: "Audio artifact",
mimeType: "audio/mpeg",
uri: "artifacts/audio.mp3",
authorId: "agent-audio",
authorType: "agent",
createdAt: "2026-04-19T10:00:00.000Z",
updatedAt: "2026-04-19T10:00:00.000Z",
},
{
id: "artifact-document",
type: "document",
title: "Document artifact",
content: "Inline document preview",
mimeType: "text/markdown",
authorId: "agent-doc",
authorType: "agent",
createdAt: "2026-04-19T09:00:00.000Z",
updatedAt: "2026-04-19T09:00:00.000Z",
},
{
id: "artifact-other",
type: "other",
title: "Other artifact",
description: "Generic binary",
mimeType: "application/octet-stream",
uri: "artifacts/data.bin",
authorId: "agent-other",
authorType: "agent",
createdAt: "2026-04-19T08:00:00.000Z",
updatedAt: "2026-04-19T08:00:00.000Z",
},
];
function setupHookDefaults(): void {
mockUseDocuments.mockReturnValue({
documents: mockTaskDocuments,
@@ -111,6 +182,13 @@ function setupHookDefaults(): void {
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
mockUseArtifacts.mockReturnValue({
artifacts: [],
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
}
describe("DocumentsView", () => {
@@ -195,6 +273,89 @@ describe("DocumentsView", () => {
expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument();
});
it("renders artifacts tab counts and all media card paths", async () => {
mockUseArtifacts.mockReturnValue({
artifacts: mockArtifacts,
loading: false,
error: null,
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
const artifactsTab = screen.getByRole("tab", { name: /show artifacts/i });
expect(artifactsTab).toHaveTextContent("5");
expect(screen.getByRole("tab", { name: /show project markdown files/i })).toHaveTextContent("2");
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveTextContent("2");
fireEvent.click(artifactsTab);
expect(screen.getByRole("tab", { name: /show artifacts/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("img", { name: "Image artifact" })).toHaveAttribute("src", "/api/artifacts/artifact-image/media");
expect(screen.getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
expect(screen.getByLabelText("Audio artifact: Audio artifact").tagName).toBe("AUDIO");
expect(screen.getByTestId("artifact-document-preview")).toHaveTextContent("Inline document preview");
expect(screen.getByTestId("artifact-other-link")).toHaveAttribute("href", "/api/artifacts/artifact-other/media");
expect(screen.getByText("agent-image")).toBeInTheDocument();
expect(screen.getByText("Image")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open task KB-001/i }));
await waitFor(() => {
expect(mockFetchTaskDetail).toHaveBeenCalledWith("KB-001", undefined);
expect(onOpenDetail).toHaveBeenCalledWith({ id: "KB-001" });
});
expect(screen.getAllByRole("button", { name: /open task/i })).toHaveLength(1);
});
it("renders artifacts empty loading error retry and mobile gallery states", async () => {
const artifactRefresh = vi.fn().mockResolvedValue(undefined);
mockUseArtifacts.mockReturnValue({
artifacts: [],
loading: false,
error: null,
refresh: artifactRefresh,
});
const { rerender, container } = render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
expect(screen.getByText("No artifacts yet.")).toBeInTheDocument();
mockUseArtifacts.mockReturnValue({
artifacts: [],
loading: true,
error: null,
refresh: artifactRefresh,
});
rerender(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
expect(screen.getByText("Loading artifacts…")).toBeInTheDocument();
mockUseArtifacts.mockReturnValue({
artifacts: [],
loading: false,
error: "artifact boom",
refresh: artifactRefresh,
});
rerender(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
expect(screen.getByText(/failed to load artifacts/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /retry loading documents/i }));
await waitFor(() => expect(artifactRefresh).toHaveBeenCalledTimes(1));
window.innerWidth = 600;
window.dispatchEvent(new Event("resize"));
mockUseArtifacts.mockReturnValue({
artifacts: mockArtifacts,
loading: false,
error: null,
refresh: artifactRefresh,
});
rerender(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
expect(container.querySelector(".documents-artifact-gallery--mobile")).toBeInTheDocument();
});
it("clicking project file shows content", async () => {
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { ArtifactWithTask } from "@fusion/core";
import { fetchArtifacts } from "../../api";
import { useArtifacts } from "../useArtifacts";
vi.mock("../../api", () => ({
fetchArtifacts: vi.fn(),
}));
const mockFetchArtifacts = vi.mocked(fetchArtifacts);
const mockArtifacts: ArtifactWithTask[] = [
{
id: "artifact-1",
type: "image",
title: "Screenshot",
authorId: "agent-1",
authorType: "agent",
taskId: "FN-1",
createdAt: "2026-06-21T00:00:00.000Z",
updatedAt: "2026-06-21T00:00:00.000Z",
},
];
describe("useArtifacts", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
window.localStorage.clear();
mockFetchArtifacts.mockResolvedValue(mockArtifacts);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
window.localStorage.clear();
});
it("loads artifacts on initial mount", async () => {
const { result } = renderHook(() => useArtifacts({ projectId: "project-1" }));
expect(result.current.loading).toBe(true);
expect(result.current.artifacts).toEqual([]);
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.artifacts).toEqual(mockArtifacts);
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-1");
});
it("propagates filter parameters to fetchArtifacts", async () => {
renderHook(() => useArtifacts({
projectId: "project-2",
type: "video",
authorId: "agent-video",
taskId: "FN-2",
searchQuery: "demo",
}));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mockFetchArtifacts).toHaveBeenCalledWith({
type: "video",
authorId: "agent-video",
taskId: "FN-2",
q: "demo",
}, "project-2");
});
it("debounces search query changes", async () => {
const { rerender } = renderHook(
({ searchQuery }) => useArtifacts({ projectId: "project-3", searchQuery }),
{ initialProps: { searchQuery: undefined as string | undefined } },
);
await act(async () => {
await vi.runAllTimersAsync();
});
mockFetchArtifacts.mockClear();
rerender({ searchQuery: "alpha" });
await act(async () => {
await vi.advanceTimersByTimeAsync(299);
});
expect(mockFetchArtifacts).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
await waitFor(() => {
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: "alpha" }, "project-3");
});
});
it("surfaces errors without clearing existing artifacts", async () => {
mockFetchArtifacts.mockResolvedValueOnce(mockArtifacts);
const { result } = renderHook(() => useArtifacts({ projectId: "project-4" }));
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts));
mockFetchArtifacts.mockRejectedValueOnce(new Error("Artifacts failed"));
await act(async () => {
await result.current.refresh();
});
expect(result.current.error).toBe("Artifacts failed");
expect(result.current.artifacts).toEqual(mockArtifacts);
});
it("refreshes artifacts on demand", async () => {
const { result } = renderHook(() => useArtifacts({ projectId: "project-5" }));
await act(async () => {
await vi.runAllTimersAsync();
});
mockFetchArtifacts.mockClear();
await act(async () => {
await result.current.refresh();
});
expect(mockFetchArtifacts).toHaveBeenCalledTimes(1);
expect(mockFetchArtifacts).toHaveBeenCalledWith({ q: undefined }, "project-5");
});
});

View File

@@ -0,0 +1,145 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { ArtifactType, ArtifactWithTask } from "@fusion/core";
import { fetchArtifacts } from "../api";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
export interface UseArtifactsResult {
/** List of artifacts across agents and tasks */
artifacts: ArtifactWithTask[];
/** Loading state - true only for initial fetch, false during refresh/search */
loading: boolean;
/** Error message if artifact fetch failed */
error: string | null;
/** Refresh artifacts from the server */
refresh: () => Promise<void>;
}
/**
* FNXC:ArtifactRegistry 2026-06-21-04:46:
* The Documents Artifacts tab lists registry entries created by any agent, user, or system actor. Mirror the documents SWR pattern so cross-agent artifact search revalidates in the background without hiding the existing gallery during debounce or manual refresh.
*/
export function useArtifacts(options?: {
/** Project ID for project-scoped fetching */
projectId?: string;
/** Filter artifacts by media type */
type?: ArtifactType;
/** Filter artifacts by author id */
authorId?: string;
/** Filter artifacts by parent task id */
taskId?: string;
/** Search query for artifact title/description */
searchQuery?: string;
}): UseArtifactsResult {
const { projectId, type, authorId, taskId, searchQuery } = options ?? {};
const filterKey = JSON.stringify({ type: type ?? null, authorId: authorId ?? null, taskId: taskId ?? null });
const cacheKey = projectId ? `${SWR_CACHE_KEYS.ARTIFACTS_PREFIX}${projectId}:${filterKey}` : null;
const [artifacts, setArtifacts] = useState<ArtifactWithTask[]>(() => {
if (!cacheKey) {
return [];
}
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return Array.isArray(cached) ? cached : [];
});
const [loading, setLoading] = useState(() => artifacts.length === 0);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const initialLoadCompleteRef = useRef(artifacts.length > 0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refresh = useCallback(async () => {
if (abortRef.current) {
abortRef.current.abort();
}
const requestController = new AbortController();
abortRef.current = requestController;
const isInitial = !initialLoadCompleteRef.current;
if (isInitial) {
setLoading(true);
}
setError(null);
try {
const fetched = await fetchArtifacts({
type,
authorId,
taskId,
q: searchQuery,
}, projectId);
if (requestController.signal.aborted) {
return;
}
setArtifacts(fetched);
if (cacheKey) {
const cachedPayload = fetched.length > 500 ? fetched.slice(0, 500) : fetched;
writeCache(cacheKey, cachedPayload, { maxBytes: 500_000 });
}
initialLoadCompleteRef.current = true;
} catch (err) {
if (requestController.signal.aborted) {
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!requestController.signal.aborted && isInitial) {
setLoading(false);
}
}
}, [authorId, cacheKey, projectId, searchQuery, taskId, type]);
useEffect(() => {
if (!cacheKey) {
initialLoadCompleteRef.current = false;
setArtifacts([]);
setLoading(true);
return;
}
const cached = readCache<ArtifactWithTask[]>(cacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
if (Array.isArray(cached)) {
setArtifacts(cached);
initialLoadCompleteRef.current = true;
setLoading(false);
} else {
initialLoadCompleteRef.current = false;
setArtifacts([]);
setLoading(true);
}
}, [cacheKey]);
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
void refresh();
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [refresh]);
useEffect(() => {
void refresh();
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, []);
return {
artifacts,
loading,
error,
refresh,
};
}

View File

@@ -17,6 +17,7 @@ export const SWR_CACHE_KEYS = {
AGENTS: "kb-dashboard-agents-cache",
AGENT_STATS: "kb-dashboard-agent-stats-cache",
DOCUMENTS_PREFIX: "kb-dashboard-documents-cache:",
ARTIFACTS_PREFIX: "kb-dashboard-artifacts-cache:",
TODO_LISTS_PREFIX: "kb-dashboard-todo-lists-cache:",
CHAT_ROOMS: "kb-dashboard-chat-rooms-cache",
CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:",

View File

@@ -0,0 +1,182 @@
// @vitest-environment node
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { Artifact, ArtifactWithTask, TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
const tempRoots: string[] = [];
async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "fusion-artifacts-routes-"));
tempRoots.push(root);
return root;
}
function makeArtifact(overrides: Partial<Artifact> = {}): Artifact {
return {
id: "artifact-1",
type: "image",
title: "Screenshot",
mimeType: "image/png",
uri: "artifacts/screenshot.png",
authorId: "agent-1",
authorType: "agent",
taskId: "FN-1",
createdAt: "2026-06-21T00:00:00.000Z",
updatedAt: "2026-06-21T00:00:00.000Z",
...overrides,
};
}
function makeApp(store: Partial<TaskStore>) {
const app = express();
app.use("/api", createApiRoutes(store as TaskStore));
return app;
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("artifacts routes", () => {
it("lists artifacts with parsed filters and clamped pagination", async () => {
const artifact: ArtifactWithTask = { ...makeArtifact(), taskTitle: "Task" };
const listArtifacts = vi.fn().mockResolvedValue([artifact]);
const app = makeApp({
getRootDir: vi.fn(() => process.cwd()),
listArtifacts,
});
const res = await REQUEST(app, "GET", "/api/artifacts?type=image&authorId=agent-1&taskId=FN-1&q=screen&limit=5000&offset=2");
expect(res.status).toBe(200);
expect(res.body).toEqual([artifact]);
expect(listArtifacts).toHaveBeenCalledWith({
type: "image",
authorId: "agent-1",
taskId: "FN-1",
search: "screen",
limit: 1000,
offset: 2,
});
});
it.each([
["/api/artifacts?type=bogus", "type"],
["/api/artifacts?limit=0", "limit"],
["/api/artifacts?limit=abc", "limit"],
["/api/artifacts?offset=-1", "offset"],
["/api/artifacts?offset=abc", "offset"],
])("rejects invalid list query %s", async (path, expectedMessage) => {
const listArtifacts = vi.fn();
const app = makeApp({
getRootDir: vi.fn(() => process.cwd()),
listArtifacts,
});
const res = await REQUEST(app, "GET", path);
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toContain(expectedMessage);
expect(listArtifacts).not.toHaveBeenCalled();
});
it("streams task-scoped artifact media with its content type", async () => {
const root = await makeRoot();
const taskDir = join(root, ".fusion", "tasks", "FN-1");
await mkdir(join(taskDir, "artifacts"), { recursive: true });
await writeFile(join(taskDir, "artifacts", "screenshot.png"), Buffer.from("image-bytes"));
const artifact = makeArtifact();
const app = makeApp({
getRootDir: vi.fn(() => root),
getTaskDir: vi.fn(() => taskDir),
getFusionDir: vi.fn(() => join(root, ".fusion")),
getArtifact: vi.fn().mockResolvedValue(artifact),
});
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("image/png");
expect(res.body).toBe("image-bytes");
});
it("streams task-less registry artifact media from the fusion artifacts directory", async () => {
const root = await makeRoot();
const fusionDir = join(root, ".fusion");
await mkdir(join(fusionDir, "artifacts"), { recursive: true });
await writeFile(join(fusionDir, "artifacts", "registry.bin"), Buffer.from("registry-bytes"));
const artifact = makeArtifact({ taskId: undefined, uri: "artifacts/registry.bin", mimeType: "application/octet-stream" });
const app = makeApp({
getRootDir: vi.fn(() => root),
getTaskDir: vi.fn(() => join(root, ".fusion", "tasks", "FN-1")),
getFusionDir: vi.fn(() => fusionDir),
getArtifact: vi.fn().mockResolvedValue(artifact),
});
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("application/octet-stream");
expect(res.body).toBe("registry-bytes");
});
it("returns inline text artifact content when no uri exists", async () => {
const artifact = makeArtifact({ uri: undefined, content: "inline text", mimeType: "text/plain" });
const app = makeApp({
getRootDir: vi.fn(() => process.cwd()),
getTaskDir: vi.fn(() => process.cwd()),
getFusionDir: vi.fn(() => process.cwd()),
getArtifact: vi.fn().mockResolvedValue(artifact),
});
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/plain");
expect(res.body).toBe("inline text");
});
it("returns 404 for missing artifact or missing file", async () => {
const root = await makeRoot();
const taskDir = join(root, ".fusion", "tasks", "FN-1");
const missingApp = makeApp({
getRootDir: vi.fn(() => root),
getArtifact: vi.fn().mockResolvedValue(null),
});
expect((await REQUEST(missingApp, "GET", "/api/artifacts/missing/media")).status).toBe(404);
const missingFileApp = makeApp({
getRootDir: vi.fn(() => root),
getTaskDir: vi.fn(() => taskDir),
getFusionDir: vi.fn(() => join(root, ".fusion")),
getArtifact: vi.fn().mockResolvedValue(makeArtifact()),
});
expect((await REQUEST(missingFileApp, "GET", "/api/artifacts/artifact-1/media")).status).toBe(404);
});
it("rejects artifact uri path traversal before streaming", async () => {
const root = await makeRoot();
const taskDir = join(root, ".fusion", "tasks", "FN-1");
const app = makeApp({
getRootDir: vi.fn(() => root),
getTaskDir: vi.fn(() => taskDir),
getFusionDir: vi.fn(() => join(root, ".fusion")),
getArtifact: vi.fn().mockResolvedValue(makeArtifact({ uri: "artifacts/../secret.txt" })),
});
const res = await REQUEST(app, "GET", "/api/artifacts/artifact-1/media");
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toContain("Invalid artifact media path");
});
});

View File

@@ -1,4 +1,5 @@
import { createReadStream } from "node:fs";
import { resolve, sep } from "node:path";
import type {
TaskStore,
Task,
@@ -12,6 +13,7 @@ import type {
DuplicateCandidate,
DuplicateMatch,
RunAuditEvent,
ArtifactType,
} from "@fusion/core";
import {
COLUMNS,
@@ -56,6 +58,25 @@ const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Pla
const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
const ARTIFACT_TYPES = new Set<ArtifactType>(["document", "image", "video", "audio", "other"]);
function isArtifactType(value: string): value is ArtifactType {
return ARTIFACT_TYPES.has(value as ArtifactType);
}
function resolveArtifactMediaPath(scopedStore: TaskStore, artifact: { taskId?: string; uri?: string }): string | null {
if (!artifact.uri) {
return null;
}
const anchorDir = artifact.taskId ? scopedStore.getTaskDir(artifact.taskId) : scopedStore.getFusionDir();
const expectedArtifactsDir = resolve(anchorDir, "artifacts");
const mediaPath = resolve(anchorDir, artifact.uri);
if (mediaPath !== expectedArtifactsDir && !mediaPath.startsWith(`${expectedArtifactsDir}${sep}`)) {
throw badRequest("Invalid artifact media path");
}
return mediaPath;
}
interface AutoSyncOutcome {
worktreePath: string | null;
@@ -2674,6 +2695,110 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
/**
* FNXC:ArtifactRegistry 2026-06-21-04:46:
* Documents view needs a cross-agent registry read surface for all artifact media classes. Keep query validation aligned with `/documents` so dashboard tabs share bounded pagination behavior while rejecting unknown artifact types before store access.
*/
router.get("/artifacts", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const {
type: typeParam,
authorId,
taskId,
q,
limit: limitStr,
offset: offsetStr,
} = req.query as Record<string, string | undefined>;
let type: ArtifactType | undefined;
if (typeParam !== undefined) {
if (!isArtifactType(typeParam)) {
throw badRequest("type must be one of: document, image, video, audio, other");
}
type = typeParam;
}
let limit = 200;
if (limitStr !== undefined) {
const parsed = parseInt(limitStr, 10);
if (isNaN(parsed) || parsed < 1) {
throw badRequest("limit must be a positive integer");
}
limit = Math.min(parsed, 1000);
}
let offset = 0;
if (offsetStr !== undefined) {
const parsed = parseInt(offsetStr, 10);
if (isNaN(parsed) || parsed < 0) {
throw badRequest("offset must be a non-negative integer");
}
offset = parsed;
}
const artifacts = await scopedStore.listArtifacts({
type,
authorId,
taskId,
search: q,
limit,
offset,
});
res.json(artifacts);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
throw new ApiError(500, err instanceof Error ? err.message : String(err));
}
});
/**
* FNXC:ArtifactRegistry 2026-06-21-04:46:
* Media artifacts stream by registry id with the persisted MIME type so images, video, and audio render inline in the Documents gallery. Binary rows are anchored under either a task `artifacts/` directory or the task-less `.fusion/artifacts/` registry; inline text rows return their content directly because they intentionally have no file uri.
*/
router.get("/artifacts/:id/media", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const artifact = await scopedStore.getArtifact(req.params.id);
if (!artifact) {
throw notFound("Artifact not found");
}
if (!artifact.uri) {
if (artifact.content === undefined) {
throw notFound("Artifact media not found");
}
res.setHeader("Content-Type", artifact.mimeType ?? "text/plain; charset=utf-8");
res.send(artifact.content);
return;
}
const mediaPath = resolveArtifactMediaPath(scopedStore, artifact);
if (!mediaPath) {
throw notFound("Artifact media not found");
}
const stream = createReadStream(mediaPath);
stream.on("error", () => {
if (!res.headersSent) {
res.status(404).json({ error: "Artifact media not found" });
} else {
res.end();
}
});
res.setHeader("Content-Type", artifact.mimeType ?? "application/octet-stream");
stream.pipe(res);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
throw new ApiError(500, err instanceof Error ? err.message : String(err));
}
});
// GET /documents — List all documents across all tasks
router.get("/documents", async (req, res) => {
try {

View File

@@ -2193,7 +2193,27 @@
"taskDocuments": "task documents",
"taskDocumentsTab": "Task Documents",
"title": "Documents",
"untitled": "Untitled"
"untitled": "Untitled",
"artifacts": "artifacts",
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
"artifactsTab": "Artifacts",
"artifactAudioLabel": "Audio artifact: {{title}}",
"artifactCardLabel": "Artifact {{title}}",
"artifactTypeAudio": "Audio",
"artifactTypeDocument": "Document",
"artifactTypeImage": "Image",
"artifactTypeOther": "Other",
"artifactTypeVideo": "Video",
"artifactVideoLabel": "Video artifact: {{title}}",
"loadingArtifacts": "Loading artifacts…",
"noArtifactPreview": "No preview available.",
"noArtifacts": "No artifacts yet.",
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
"openArtifactMedia": "Open artifact media",
"openTaskAria": "Open task {{taskId}}: {{title}}",
"searchArtifacts": "Search artifacts…",
"showArtifacts": "Show artifacts",
"untitledArtifact": "Untitled artifact"
},
"droidCli": {
"active": "Active",

View File

@@ -2193,7 +2193,27 @@
"taskDocuments": "documentos de tareas",
"taskDocumentsTab": "Documentos de tareas",
"title": "Documentos",
"untitled": "Sin título"
"untitled": "Sin título",
"artifacts": "artifacts",
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
"artifactsTab": "Artifacts",
"artifactAudioLabel": "Audio artifact: {{title}}",
"artifactCardLabel": "Artifact {{title}}",
"artifactTypeAudio": "Audio",
"artifactTypeDocument": "Document",
"artifactTypeImage": "Image",
"artifactTypeOther": "Other",
"artifactTypeVideo": "Video",
"artifactVideoLabel": "Video artifact: {{title}}",
"loadingArtifacts": "Loading artifacts…",
"noArtifactPreview": "No preview available.",
"noArtifacts": "No artifacts yet.",
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
"openArtifactMedia": "Open artifact media",
"openTaskAria": "Open task {{taskId}}: {{title}}",
"searchArtifacts": "Search artifacts…",
"showArtifacts": "Show artifacts",
"untitledArtifact": "Untitled artifact"
},
"droidCli": {
"active": "Activo",
@@ -2292,7 +2312,10 @@
"status": "Estado del ejecutor",
"stuck": "Atascado",
"temporary": "Temporal",
"todoStatus": ""
"todoStatus": "",
"engineControls": "Engine controls",
"openEngineControlsForState": "Open engine controls for {{state}} state",
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
},
"fileBrowser": {
"back": "Volver a la lista de archivos",

View File

@@ -2193,7 +2193,27 @@
"taskDocuments": "documents des tâches",
"taskDocumentsTab": "Documents des tâches",
"title": "Documents",
"untitled": "Sans titre"
"untitled": "Sans titre",
"artifacts": "artifacts",
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
"artifactsTab": "Artifacts",
"artifactAudioLabel": "Audio artifact: {{title}}",
"artifactCardLabel": "Artifact {{title}}",
"artifactTypeAudio": "Audio",
"artifactTypeDocument": "Document",
"artifactTypeImage": "Image",
"artifactTypeOther": "Other",
"artifactTypeVideo": "Video",
"artifactVideoLabel": "Video artifact: {{title}}",
"loadingArtifacts": "Loading artifacts…",
"noArtifactPreview": "No preview available.",
"noArtifacts": "No artifacts yet.",
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
"openArtifactMedia": "Open artifact media",
"openTaskAria": "Open task {{taskId}}: {{title}}",
"searchArtifacts": "Search artifacts…",
"showArtifacts": "Show artifacts",
"untitledArtifact": "Untitled artifact"
},
"droidCli": {
"active": "Actif",
@@ -2292,7 +2312,10 @@
"status": "État de l'exécuteur",
"stuck": "Bloqué",
"temporary": "Temporaire",
"todoStatus": ""
"todoStatus": "",
"engineControls": "Engine controls",
"openEngineControlsForState": "Open engine controls for {{state}} state",
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
},
"fileBrowser": {
"back": "Retour à la liste des fichiers",

View File

@@ -2193,7 +2193,27 @@
"taskDocuments": "작업 문서",
"taskDocumentsTab": "작업 문서",
"title": "문서",
"untitled": "제목 없음"
"untitled": "제목 없음",
"artifacts": "artifacts",
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
"artifactsTab": "Artifacts",
"artifactAudioLabel": "Audio artifact: {{title}}",
"artifactCardLabel": "Artifact {{title}}",
"artifactTypeAudio": "Audio",
"artifactTypeDocument": "Document",
"artifactTypeImage": "Image",
"artifactTypeOther": "Other",
"artifactTypeVideo": "Video",
"artifactVideoLabel": "Video artifact: {{title}}",
"loadingArtifacts": "Loading artifacts…",
"noArtifactPreview": "No preview available.",
"noArtifacts": "No artifacts yet.",
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
"openArtifactMedia": "Open artifact media",
"openTaskAria": "Open task {{taskId}}: {{title}}",
"searchArtifacts": "Search artifacts…",
"showArtifacts": "Show artifacts",
"untitledArtifact": "Untitled artifact"
},
"droidCli": {
"active": "활성",
@@ -2292,7 +2312,10 @@
"status": "실행기 상태",
"stuck": "중단됨",
"temporary": "임시",
"todoStatus": ""
"todoStatus": "",
"engineControls": "Engine controls",
"openEngineControlsForState": "Open engine controls for {{state}} state",
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
},
"fileBrowser": {
"back": "파일 목록으로 돌아가기",

View File

@@ -2193,7 +2193,27 @@
"taskDocuments": "任务文档",
"taskDocumentsTab": "任务文档",
"title": "文档",
"untitled": "未命名"
"untitled": "未命名",
"artifacts": "artifacts",
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
"artifactsTab": "Artifacts",
"artifactAudioLabel": "Audio artifact: {{title}}",
"artifactCardLabel": "Artifact {{title}}",
"artifactTypeAudio": "Audio",
"artifactTypeDocument": "Document",
"artifactTypeImage": "Image",
"artifactTypeOther": "Other",
"artifactTypeVideo": "Video",
"artifactVideoLabel": "Video artifact: {{title}}",
"loadingArtifacts": "Loading artifacts…",
"noArtifactPreview": "No preview available.",
"noArtifacts": "No artifacts yet.",
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
"openArtifactMedia": "Open artifact media",
"openTaskAria": "Open task {{taskId}}: {{title}}",
"searchArtifacts": "Search artifacts…",
"showArtifacts": "Show artifacts",
"untitledArtifact": "Untitled artifact"
},
"droidCli": {
"active": "活跃",
@@ -2292,7 +2312,10 @@
"status": "执行器状态",
"stuck": "卡顿",
"temporary": "临时",
"todoStatus": ""
"todoStatus": "",
"engineControls": "Engine controls",
"openEngineControlsForState": "Open engine controls for {{state}} state",
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
},
"fileBrowser": {
"back": "返回文件列表",

View File

@@ -2193,7 +2193,27 @@
"taskDocuments": "工作文件",
"taskDocumentsTab": "工作文件",
"title": "文件",
"untitled": "未命名"
"untitled": "未命名",
"artifacts": "artifacts",
"artifactsCreatedBy": "Artifacts are created by agents, users, and system tools.",
"artifactsTab": "Artifacts",
"artifactAudioLabel": "Audio artifact: {{title}}",
"artifactCardLabel": "Artifact {{title}}",
"artifactTypeAudio": "Audio",
"artifactTypeDocument": "Document",
"artifactTypeImage": "Image",
"artifactTypeOther": "Other",
"artifactTypeVideo": "Video",
"artifactVideoLabel": "Video artifact: {{title}}",
"loadingArtifacts": "Loading artifacts…",
"noArtifactPreview": "No preview available.",
"noArtifacts": "No artifacts yet.",
"noMatchArtifacts": "No artifacts match \"{{query}}\".",
"openArtifactMedia": "Open artifact media",
"openTaskAria": "Open task {{taskId}}: {{title}}",
"searchArtifacts": "Search artifacts…",
"showArtifacts": "Show artifacts",
"untitledArtifact": "Untitled artifact"
},
"droidCli": {
"active": "活躍",
@@ -2292,7 +2312,10 @@
"status": "執行器狀態",
"stuck": "卡住",
"temporary": "暫時",
"todoStatus": ""
"todoStatus": "",
"engineControls": "Engine controls",
"openEngineControlsForState": "Open engine controls for {{state}} state",
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
},
"fileBrowser": {
"back": "返回檔案清單",

View File

@@ -17,10 +17,10 @@
"packages/core/src/central-core.ts": 3854,
"packages/core/src/db.ts": 5840,
"packages/core/src/mission-store.ts": 4382,
"packages/core/src/store.ts": 16755,
"packages/core/src/store.ts": 16776,
"packages/core/src/types.ts": 7256,
"packages/dashboard/app/App.tsx": 2298,
"packages/dashboard/app/api/legacy.ts": 10568,
"packages/dashboard/app/App.tsx": 2303,
"packages/dashboard/app/api/legacy.ts": 10612,
"packages/dashboard/app/components/AgentDetailView.tsx": 5400,
"packages/dashboard/app/components/AgentsView.tsx": 2101,
"packages/dashboard/app/components/ChatView.tsx": 3964,
@@ -31,7 +31,7 @@
"packages/dashboard/app/components/PlanningModeModal.tsx": 3319,
"packages/dashboard/app/components/QuickChatFAB.tsx": 3560,
"packages/dashboard/app/components/QuickEntryBox.tsx": 2207,
"packages/dashboard/app/components/SettingsModal.tsx": 3254,
"packages/dashboard/app/components/SettingsModal.tsx": 3251,
"packages/dashboard/app/components/TaskCard.tsx": 2528,
"packages/dashboard/app/components/TaskDetailModal.tsx": 4569,
"packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4457,
@@ -47,7 +47,7 @@
"packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4526,
"packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5414,
"packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121,
"packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2366,
"packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2407,
"packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917,
"packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2297,
"packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5578,
@@ -75,10 +75,10 @@
"packages/dashboard/src/github.ts": 4178,
"packages/dashboard/src/mission-routes.ts": 3948,
"packages/dashboard/src/planning.ts": 2696,
"packages/dashboard/src/routes.ts": 5251,
"packages/dashboard/src/routes.ts": 5296,
"packages/dashboard/src/routes/register-git-github.ts": 5637,
"packages/dashboard/src/routes/register-settings-memory-routes.ts": 2421,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 3738,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 3863,
"packages/dashboard/src/server.ts": 2378,
"packages/engine/src/__tests__/executor-pause.test.ts": 2974,
"packages/engine/src/__tests__/executor-prompt.test.ts": 2572,
@@ -97,7 +97,7 @@
"packages/engine/src/__tests__/triage.test.ts": 4511,
"packages/engine/src/agent-heartbeat.ts": 4553,
"packages/engine/src/agent-tools.ts": 3584,
"packages/engine/src/executor.ts": 16018,
"packages/engine/src/executor.ts": 16034,
"packages/engine/src/merger.ts": 12643,
"packages/engine/src/pi.ts": 2435,
"packages/engine/src/project-engine.ts": 3663,