diff --git a/.changeset/fn-6779-artifacts-gallery.md b/.changeset/fn-6779-artifacts-gallery.md new file mode 100644 index 0000000000..8e709b11b5 --- /dev/null +++ b/.changeset/fn-6779-artifacts-gallery.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1d86040914..5f086d1352 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index cef95b7a1b..6767c4231a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { + 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(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, diff --git a/packages/dashboard/app/components/DocumentsView.css b/packages/dashboard/app/components/DocumentsView.css index a0447ace70..eb666de925 100644 --- a/packages/dashboard/app/components/DocumentsView.css +++ b/packages/dashboard/app/components/DocumentsView.css @@ -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; diff --git a/packages/dashboard/app/components/DocumentsView.tsx b/packages/dashboard/app/components/DocumentsView.tsx index bee41f4b2a..33da3d48bc 100644 --- a/packages/dashboard/app/components/DocumentsView.tsx +++ b/packages/dashboard/app/components/DocumentsView.tsx @@ -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 @@ -176,6 +183,83 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta ); } +function getArtifactTypeLabel(t: ReturnType>["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 {title}; + case "video": + return