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:
5
.changeset/fn-6779-artifacts-gallery.md
Normal file
5
.changeset/fn-6779-artifacts-gallery.md
Normal 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.
|
||||||
@@ -471,14 +471,16 @@ For per-run aggregation, `GET /api/agents/:id/runs/:runId/cited-goals` returns `
|
|||||||
|
|
||||||
## Documents View
|
## Documents View
|
||||||
|
|
||||||
Documents view aggregates task documents and project markdown files.
|
Documents view aggregates task documents, project markdown files, and registered artifacts.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
|
|
||||||
- Group task documents by task ID (with revision history metadata)
|
- Group task documents by task ID (with revision history metadata)
|
||||||
- Search documents across tasks
|
- Search documents across tasks
|
||||||
- Open project markdown files with inline preview
|
- 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
|
- 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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import type {
|
|||||||
TaskDocument,
|
TaskDocument,
|
||||||
TaskDocumentRevision,
|
TaskDocumentRevision,
|
||||||
TaskDocumentWithTask,
|
TaskDocumentWithTask,
|
||||||
|
Artifact,
|
||||||
|
ArtifactType,
|
||||||
|
ArtifactWithTask,
|
||||||
|
|
||||||
Message,
|
Message,
|
||||||
MessageMetadata,
|
MessageMetadata,
|
||||||
@@ -1474,6 +1477,37 @@ export interface MarkdownFileListResponse {
|
|||||||
files: MarkdownFileEntry[];
|
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(
|
export async function fetchAllDocuments(
|
||||||
options?: FetchAllDocumentsOptions,
|
options?: FetchAllDocumentsOptions,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
|
|||||||
@@ -617,6 +617,126 @@
|
|||||||
font-family: var(--font-primary);
|
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) {
|
@media (max-width: 768px) {
|
||||||
/* Documents View mobile */
|
/* Documents View mobile */
|
||||||
@@ -727,6 +847,28 @@
|
|||||||
font-size: 12px;
|
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: ensure adequate touch target on mobile */
|
||||||
.document-mode-toggle {
|
.document-mode-toggle {
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import "./DocumentsView.css";
|
import "./DocumentsView.css";
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
|
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft, FileText, 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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
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 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 { useDocuments } from "../hooks/useDocuments";
|
||||||
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
||||||
import { useSelectionComment } from "../hooks/useSelectionComment";
|
import { useSelectionComment } from "../hooks/useSelectionComment";
|
||||||
@@ -15,7 +16,7 @@ import { LoadingSpinner } from "./LoadingSpinner";
|
|||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
type DocumentsTab = "project" | "tasks";
|
type DocumentsTab = "project" | "tasks" | "artifacts";
|
||||||
|
|
||||||
export interface DocumentsViewProps {
|
export interface DocumentsViewProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -39,6 +40,12 @@ interface TaskGroupProps {
|
|||||||
onToggleMarkdown: (docId: string) => void;
|
onToggleMarkdown: (docId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ArtifactCardProps {
|
||||||
|
artifact: ArtifactWithTask;
|
||||||
|
projectId?: string;
|
||||||
|
onOpenTask: (taskId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestamp(iso?: string): string {
|
function formatTimestamp(iso?: string): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
@@ -154,7 +161,7 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
|
|||||||
<button
|
<button
|
||||||
className="documents-group-task-link"
|
className="documents-group-task-link"
|
||||||
onClick={() => onOpenTask(taskId)}
|
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")}
|
{t("documents.openTask", "Open task")}
|
||||||
</button>
|
</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) {
|
export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelectionToTask }: DocumentsViewProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
|
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
|
||||||
@@ -200,6 +284,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
|
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
|
||||||
|
|
||||||
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
|
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
|
||||||
|
const artifactSearchQuery = activeTab === "artifacts" ? searchQuery.trim() : "";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
documents,
|
documents,
|
||||||
@@ -219,6 +304,16 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
refresh: refreshProjectFiles,
|
refresh: refreshProjectFiles,
|
||||||
} = useProjectMarkdownFiles(projectId, { showHidden: showHiddenProjectFiles });
|
} = useProjectMarkdownFiles(projectId, { showHidden: showHiddenProjectFiles });
|
||||||
|
|
||||||
|
const {
|
||||||
|
artifacts,
|
||||||
|
loading: artifactsLoading,
|
||||||
|
error: artifactsError,
|
||||||
|
refresh: refreshArtifacts,
|
||||||
|
} = useArtifacts({
|
||||||
|
projectId,
|
||||||
|
searchQuery: artifactSearchQuery || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateMobile = () => {
|
const updateMobile = () => {
|
||||||
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||||
@@ -245,7 +340,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialTabSetRef.current || documentsLoading || projectFilesLoading) {
|
if (initialTabSetRef.current || documentsLoading || projectFilesLoading || artifactsLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,10 +348,12 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
setActiveTab("project");
|
setActiveTab("project");
|
||||||
} else if (documents.length > 0) {
|
} else if (documents.length > 0) {
|
||||||
setActiveTab("tasks");
|
setActiveTab("tasks");
|
||||||
|
} else if (artifacts.length > 0) {
|
||||||
|
setActiveTab("artifacts");
|
||||||
}
|
}
|
||||||
|
|
||||||
initialTabSetRef.current = true;
|
initialTabSetRef.current = true;
|
||||||
}, [documents.length, documentsLoading, projectFiles.length, projectFilesLoading]);
|
}, [artifacts.length, artifactsLoading, documents.length, documentsLoading, projectFiles.length, projectFilesLoading]);
|
||||||
|
|
||||||
const groupedDocuments = useMemo(() => {
|
const groupedDocuments = useMemo(() => {
|
||||||
const groups = new Map<string, TaskDocumentWithTask[]>();
|
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 () => {
|
const handleRetry = useCallback(async () => {
|
||||||
if (activeTab === "project") {
|
if (activeTab === "project") {
|
||||||
await refreshProjectFiles();
|
await refreshProjectFiles();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshDocuments();
|
if (activeTab === "tasks") {
|
||||||
}, [activeTab, refreshProjectFiles, refreshDocuments]);
|
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 ? (
|
const selectionPopover = selectedFile && onSendSelectionToTask && activeProjectSelection ? (
|
||||||
<SelectionCommentPopover
|
<SelectionCommentPopover
|
||||||
selectedText={activeProjectSelection.selectedText}
|
selectedText={activeProjectSelection.selectedText}
|
||||||
@@ -396,7 +497,9 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
|
|
||||||
const searchPlaceholder = activeTab === "project"
|
const searchPlaceholder = activeTab === "project"
|
||||||
? t("documents.searchProjectFiles", "Search project markdown files…")
|
? 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 (
|
return (
|
||||||
<div className="documents-view">
|
<div className="documents-view">
|
||||||
@@ -433,6 +536,20 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
{t("documents.taskDocumentsTab", "Task Documents")}
|
{t("documents.taskDocumentsTab", "Task Documents")}
|
||||||
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{activeTab === "project" && (
|
{activeTab === "project" && (
|
||||||
@@ -474,7 +591,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
<div className="documents-view-content">
|
<div className="documents-view-content">
|
||||||
{activeError ? (
|
{activeError ? (
|
||||||
<div className="documents-view-error">
|
<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")}>
|
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label={t("documents.retryLoading", "Retry loading documents")}>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
{t("documents.retry", "Retry")}
|
{t("documents.retry", "Retry")}
|
||||||
@@ -575,6 +692,41 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
) : documentsLoading && documents.length === 0 ? (
|
||||||
<div className="documents-view-loading">
|
<div className="documents-view-loading">
|
||||||
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
|
<p><LoadingSpinner label={t("documents.loadingTaskDocuments", "Loading task documents…")} /></p>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
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 { DocumentsView } from "../DocumentsView";
|
||||||
import { fetchTaskDetail, fetchWorkspaceFileContent } from "../../api";
|
import { fetchTaskDetail, fetchWorkspaceFileContent } from "../../api";
|
||||||
|
import { useArtifacts } from "../../hooks/useArtifacts";
|
||||||
import { useDocuments } from "../../hooks/useDocuments";
|
import { useDocuments } from "../../hooks/useDocuments";
|
||||||
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
|
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
|
||||||
|
|
||||||
@@ -11,17 +12,24 @@ vi.mock("../../api", () => ({
|
|||||||
fetchAllDocuments: vi.fn(),
|
fetchAllDocuments: vi.fn(),
|
||||||
fetchWorkspaceFileContent: vi.fn(),
|
fetchWorkspaceFileContent: vi.fn(),
|
||||||
fetchTaskDetail: vi.fn(),
|
fetchTaskDetail: vi.fn(),
|
||||||
|
fetchArtifacts: vi.fn(),
|
||||||
|
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useDocuments", () => ({
|
vi.mock("../../hooks/useDocuments", () => ({
|
||||||
useDocuments: vi.fn(),
|
useDocuments: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useArtifacts", () => ({
|
||||||
|
useArtifacts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useProjectMarkdownFiles", () => ({
|
vi.mock("../../hooks/useProjectMarkdownFiles", () => ({
|
||||||
useProjectMarkdownFiles: vi.fn(),
|
useProjectMarkdownFiles: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockUseDocuments = vi.mocked(useDocuments);
|
const mockUseDocuments = vi.mocked(useDocuments);
|
||||||
|
const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||||
const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
|
const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
|
||||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||||
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
|
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
|
||||||
@@ -96,6 +104,69 @@ const mockHiddenProjectFile = {
|
|||||||
mtime: "2026-04-19T10:00:00.000Z",
|
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 {
|
function setupHookDefaults(): void {
|
||||||
mockUseDocuments.mockReturnValue({
|
mockUseDocuments.mockReturnValue({
|
||||||
documents: mockTaskDocuments,
|
documents: mockTaskDocuments,
|
||||||
@@ -111,6 +182,13 @@ function setupHookDefaults(): void {
|
|||||||
error: null,
|
error: null,
|
||||||
refresh: vi.fn().mockResolvedValue(undefined),
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mockUseArtifacts.mockReturnValue({
|
||||||
|
artifacts: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DocumentsView", () => {
|
describe("DocumentsView", () => {
|
||||||
@@ -195,6 +273,89 @@ describe("DocumentsView", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Open README.md" })).not.toBeInTheDocument();
|
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 () => {
|
it("clicking project file shows content", async () => {
|
||||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||||
|
|
||||||
|
|||||||
136
packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts
Normal file
136
packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
145
packages/dashboard/app/hooks/useArtifacts.ts
Normal file
145
packages/dashboard/app/hooks/useArtifacts.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export const SWR_CACHE_KEYS = {
|
|||||||
AGENTS: "kb-dashboard-agents-cache",
|
AGENTS: "kb-dashboard-agents-cache",
|
||||||
AGENT_STATS: "kb-dashboard-agent-stats-cache",
|
AGENT_STATS: "kb-dashboard-agent-stats-cache",
|
||||||
DOCUMENTS_PREFIX: "kb-dashboard-documents-cache:",
|
DOCUMENTS_PREFIX: "kb-dashboard-documents-cache:",
|
||||||
|
ARTIFACTS_PREFIX: "kb-dashboard-artifacts-cache:",
|
||||||
TODO_LISTS_PREFIX: "kb-dashboard-todo-lists-cache:",
|
TODO_LISTS_PREFIX: "kb-dashboard-todo-lists-cache:",
|
||||||
CHAT_ROOMS: "kb-dashboard-chat-rooms-cache",
|
CHAT_ROOMS: "kb-dashboard-chat-rooms-cache",
|
||||||
CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:",
|
CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:",
|
||||||
|
|||||||
182
packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts
Normal file
182
packages/dashboard/src/routes/__tests__/artifacts-routes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
|
import { resolve, sep } from "node:path";
|
||||||
import type {
|
import type {
|
||||||
TaskStore,
|
TaskStore,
|
||||||
Task,
|
Task,
|
||||||
@@ -12,6 +13,7 @@ import type {
|
|||||||
DuplicateCandidate,
|
DuplicateCandidate,
|
||||||
DuplicateMatch,
|
DuplicateMatch,
|
||||||
RunAuditEvent,
|
RunAuditEvent,
|
||||||
|
ArtifactType,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
COLUMNS,
|
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_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 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 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 {
|
interface AutoSyncOutcome {
|
||||||
worktreePath: string | null;
|
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
|
// GET /documents — List all documents across all tasks
|
||||||
router.get("/documents", async (req, res) => {
|
router.get("/documents", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2193,7 +2193,27 @@
|
|||||||
"taskDocuments": "task documents",
|
"taskDocuments": "task documents",
|
||||||
"taskDocumentsTab": "Task Documents",
|
"taskDocumentsTab": "Task Documents",
|
||||||
"title": "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": {
|
"droidCli": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@@ -2193,7 +2193,27 @@
|
|||||||
"taskDocuments": "documentos de tareas",
|
"taskDocuments": "documentos de tareas",
|
||||||
"taskDocumentsTab": "Documentos de tareas",
|
"taskDocumentsTab": "Documentos de tareas",
|
||||||
"title": "Documentos",
|
"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": {
|
"droidCli": {
|
||||||
"active": "Activo",
|
"active": "Activo",
|
||||||
@@ -2292,7 +2312,10 @@
|
|||||||
"status": "Estado del ejecutor",
|
"status": "Estado del ejecutor",
|
||||||
"stuck": "Atascado",
|
"stuck": "Atascado",
|
||||||
"temporary": "Temporal",
|
"temporary": "Temporal",
|
||||||
"todoStatus": ""
|
"todoStatus": "",
|
||||||
|
"engineControls": "Engine controls",
|
||||||
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
"fileBrowser": {
|
"fileBrowser": {
|
||||||
"back": "Volver a la lista de archivos",
|
"back": "Volver a la lista de archivos",
|
||||||
|
|||||||
@@ -2193,7 +2193,27 @@
|
|||||||
"taskDocuments": "documents des tâches",
|
"taskDocuments": "documents des tâches",
|
||||||
"taskDocumentsTab": "Documents des tâches",
|
"taskDocumentsTab": "Documents des tâches",
|
||||||
"title": "Documents",
|
"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": {
|
"droidCli": {
|
||||||
"active": "Actif",
|
"active": "Actif",
|
||||||
@@ -2292,7 +2312,10 @@
|
|||||||
"status": "État de l'exécuteur",
|
"status": "État de l'exécuteur",
|
||||||
"stuck": "Bloqué",
|
"stuck": "Bloqué",
|
||||||
"temporary": "Temporaire",
|
"temporary": "Temporaire",
|
||||||
"todoStatus": ""
|
"todoStatus": "",
|
||||||
|
"engineControls": "Engine controls",
|
||||||
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
"fileBrowser": {
|
"fileBrowser": {
|
||||||
"back": "Retour à la liste des fichiers",
|
"back": "Retour à la liste des fichiers",
|
||||||
|
|||||||
@@ -2193,7 +2193,27 @@
|
|||||||
"taskDocuments": "작업 문서",
|
"taskDocuments": "작업 문서",
|
||||||
"taskDocumentsTab": "작업 문서",
|
"taskDocumentsTab": "작업 문서",
|
||||||
"title": "문서",
|
"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": {
|
"droidCli": {
|
||||||
"active": "활성",
|
"active": "활성",
|
||||||
@@ -2292,7 +2312,10 @@
|
|||||||
"status": "실행기 상태",
|
"status": "실행기 상태",
|
||||||
"stuck": "중단됨",
|
"stuck": "중단됨",
|
||||||
"temporary": "임시",
|
"temporary": "임시",
|
||||||
"todoStatus": ""
|
"todoStatus": "",
|
||||||
|
"engineControls": "Engine controls",
|
||||||
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
"fileBrowser": {
|
"fileBrowser": {
|
||||||
"back": "파일 목록으로 돌아가기",
|
"back": "파일 목록으로 돌아가기",
|
||||||
|
|||||||
@@ -2193,7 +2193,27 @@
|
|||||||
"taskDocuments": "任务文档",
|
"taskDocuments": "任务文档",
|
||||||
"taskDocumentsTab": "任务文档",
|
"taskDocumentsTab": "任务文档",
|
||||||
"title": "文档",
|
"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": {
|
"droidCli": {
|
||||||
"active": "活跃",
|
"active": "活跃",
|
||||||
@@ -2292,7 +2312,10 @@
|
|||||||
"status": "执行器状态",
|
"status": "执行器状态",
|
||||||
"stuck": "卡顿",
|
"stuck": "卡顿",
|
||||||
"temporary": "临时",
|
"temporary": "临时",
|
||||||
"todoStatus": ""
|
"todoStatus": "",
|
||||||
|
"engineControls": "Engine controls",
|
||||||
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
"fileBrowser": {
|
"fileBrowser": {
|
||||||
"back": "返回文件列表",
|
"back": "返回文件列表",
|
||||||
|
|||||||
@@ -2193,7 +2193,27 @@
|
|||||||
"taskDocuments": "工作文件",
|
"taskDocuments": "工作文件",
|
||||||
"taskDocumentsTab": "工作文件",
|
"taskDocumentsTab": "工作文件",
|
||||||
"title": "文件",
|
"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": {
|
"droidCli": {
|
||||||
"active": "活躍",
|
"active": "活躍",
|
||||||
@@ -2292,7 +2312,10 @@
|
|||||||
"status": "執行器狀態",
|
"status": "執行器狀態",
|
||||||
"stuck": "卡住",
|
"stuck": "卡住",
|
||||||
"temporary": "暫時",
|
"temporary": "暫時",
|
||||||
"todoStatus": ""
|
"todoStatus": "",
|
||||||
|
"engineControls": "Engine controls",
|
||||||
|
"openEngineControlsForState": "Open engine controls for {{state}} state",
|
||||||
|
"triageDisabledWhileStopped": "Start the AI engine before changing triage scheduling"
|
||||||
},
|
},
|
||||||
"fileBrowser": {
|
"fileBrowser": {
|
||||||
"back": "返回檔案清單",
|
"back": "返回檔案清單",
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
"packages/core/src/central-core.ts": 3854,
|
"packages/core/src/central-core.ts": 3854,
|
||||||
"packages/core/src/db.ts": 5840,
|
"packages/core/src/db.ts": 5840,
|
||||||
"packages/core/src/mission-store.ts": 4382,
|
"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/core/src/types.ts": 7256,
|
||||||
"packages/dashboard/app/App.tsx": 2298,
|
"packages/dashboard/app/App.tsx": 2303,
|
||||||
"packages/dashboard/app/api/legacy.ts": 10568,
|
"packages/dashboard/app/api/legacy.ts": 10612,
|
||||||
"packages/dashboard/app/components/AgentDetailView.tsx": 5400,
|
"packages/dashboard/app/components/AgentDetailView.tsx": 5400,
|
||||||
"packages/dashboard/app/components/AgentsView.tsx": 2101,
|
"packages/dashboard/app/components/AgentsView.tsx": 2101,
|
||||||
"packages/dashboard/app/components/ChatView.tsx": 3964,
|
"packages/dashboard/app/components/ChatView.tsx": 3964,
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"packages/dashboard/app/components/PlanningModeModal.tsx": 3319,
|
"packages/dashboard/app/components/PlanningModeModal.tsx": 3319,
|
||||||
"packages/dashboard/app/components/QuickChatFAB.tsx": 3560,
|
"packages/dashboard/app/components/QuickChatFAB.tsx": 3560,
|
||||||
"packages/dashboard/app/components/QuickEntryBox.tsx": 2207,
|
"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/TaskCard.tsx": 2528,
|
||||||
"packages/dashboard/app/components/TaskDetailModal.tsx": 4569,
|
"packages/dashboard/app/components/TaskDetailModal.tsx": 4569,
|
||||||
"packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4457,
|
"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__/QuickEntryBox.test.tsx": 4526,
|
||||||
"packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5414,
|
"packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5414,
|
||||||
"packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121,
|
"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.inline-editing-and-integrations.test.tsx": 2917,
|
||||||
"packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2297,
|
"packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2297,
|
||||||
"packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5578,
|
"packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5578,
|
||||||
@@ -75,10 +75,10 @@
|
|||||||
"packages/dashboard/src/github.ts": 4178,
|
"packages/dashboard/src/github.ts": 4178,
|
||||||
"packages/dashboard/src/mission-routes.ts": 3948,
|
"packages/dashboard/src/mission-routes.ts": 3948,
|
||||||
"packages/dashboard/src/planning.ts": 2696,
|
"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-git-github.ts": 5637,
|
||||||
"packages/dashboard/src/routes/register-settings-memory-routes.ts": 2421,
|
"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/dashboard/src/server.ts": 2378,
|
||||||
"packages/engine/src/__tests__/executor-pause.test.ts": 2974,
|
"packages/engine/src/__tests__/executor-pause.test.ts": 2974,
|
||||||
"packages/engine/src/__tests__/executor-prompt.test.ts": 2572,
|
"packages/engine/src/__tests__/executor-prompt.test.ts": 2572,
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
"packages/engine/src/__tests__/triage.test.ts": 4511,
|
"packages/engine/src/__tests__/triage.test.ts": 4511,
|
||||||
"packages/engine/src/agent-heartbeat.ts": 4553,
|
"packages/engine/src/agent-heartbeat.ts": 4553,
|
||||||
"packages/engine/src/agent-tools.ts": 3584,
|
"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/merger.ts": 12643,
|
||||||
"packages/engine/src/pi.ts": 2435,
|
"packages/engine/src/pi.ts": 2435,
|
||||||
"packages/engine/src/project-engine.ts": 3663,
|
"packages/engine/src/project-engine.ts": 3663,
|
||||||
|
|||||||
Reference in New Issue
Block a user