From 9024f3a63915957f2ff33bbc3b79e9fe475d7e79 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 22:24:06 -0700 Subject: [PATCH] feat: agent-created visual artifacts end-to-end + redesigned category gallery with doc editing Agents could never get screenshots/wireframes/mocks into the Artifacts view: fn_artifact_register was gated on assignedAgentId (never set in default ephemeral mode), the only image payload source was inline base64, and no prompt ever told agents to register visual deliverables. - always expose fn_artifact_register to executor sessions ("executor" author fallback), resolve relative paths against the task worktree, and default taskId to the executing task (heartbeat task lane too) - add a `path` payload source: file read with 50MB cap, extension MIME inference, PNG/JPEG/GIF/WebP signature + SVG sniff validation, persisted through managed artifact storage - executor/heartbeat/planning prompts + engine-tools reference now instruct agents to register screenshots, wireframes, mockups, and recordings - new ArtifactsGallery: Images/Docs/PDFs/Videos/Audio/Other category sections and filter chips, visual tile grid + lightbox, embedded PDF viewer, audio player rows, download rows; mobile-responsive down to the 768px breakpoint - doc artifacts open a full viewer rendered as markdown by default with an in-place edit mode using the shared CodeMirror FileEditor; persisted via new GET/PATCH /api/artifacts/:id + TaskStore.updateArtifact and live-refreshed through the new artifact:updated SSE event Co-Authored-By: Claude Fable 5 --- .changeset/artifact-pipeline-and-gallery.md | 7 + docs/agents.md | 2 +- docs/dashboard-guide.md | 16 +- docs/screenshots/artifacts-doc-edit.png | 3 + docs/screenshots/artifacts-doc-viewer.png | 3 + docs/screenshots/artifacts-gallery.png | 3 + docs/screenshots/artifacts-lightbox.png | 3 + docs/screenshots/artifacts-mobile.png | 3 + docs/screenshots/artifacts-pdf-viewer.png | 3 + docs/storage.md | 2 + .../skill/fusion/references/engine-tools.md | 2 +- packages/core/src/agent-prompts.ts | 2 + packages/core/src/store.ts | 44 ++ packages/dashboard/app/api/legacy.ts | 22 + .../app/components/ArtifactsGallery.css | 544 ++++++++++++++ .../app/components/ArtifactsGallery.tsx | 701 ++++++++++++++++++ .../app/components/DocumentsView.tsx | 186 +---- .../__tests__/DocumentsView.test.tsx | 78 +- packages/dashboard/app/hooks/useArtifacts.ts | 2 + .../artifacts-route-integration.test.ts | 59 ++ .../routes/register-task-workflow-routes.ts | 68 ++ packages/dashboard/src/sse.ts | 7 + .../__tests__/agent-artifact-tools.test.ts | 129 +++- packages/engine/src/agent-heartbeat.ts | 5 +- packages/engine/src/agent-tools.ts | 138 +++- packages/engine/src/executor.ts | 34 +- 26 files changed, 1859 insertions(+), 207 deletions(-) create mode 100644 .changeset/artifact-pipeline-and-gallery.md create mode 100644 docs/screenshots/artifacts-doc-edit.png create mode 100644 docs/screenshots/artifacts-doc-viewer.png create mode 100644 docs/screenshots/artifacts-gallery.png create mode 100644 docs/screenshots/artifacts-lightbox.png create mode 100644 docs/screenshots/artifacts-mobile.png create mode 100644 docs/screenshots/artifacts-pdf-viewer.png create mode 100644 packages/dashboard/app/components/ArtifactsGallery.css create mode 100644 packages/dashboard/app/components/ArtifactsGallery.tsx diff --git a/.changeset/artifact-pipeline-and-gallery.md b/.changeset/artifact-pipeline-and-gallery.md new file mode 100644 index 0000000000..86653960e1 --- /dev/null +++ b/.changeset/artifact-pipeline-and-gallery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Agents now save screenshots/wireframes/mocks as artifacts, shown in a redesigned category gallery with doc editing. +category: feature +dev: fn_artifact_register gains a `path` payload source (file copied into managed storage, MIME inference, image signature validation) and is now always exposed to executor sessions (previously missing in ephemeral mode) with worktree-relative path resolution and executing-task default taskId; executor/planning prompts instruct agents to register visual deliverables; new `GET`/`PATCH /api/artifacts/:id` routes plus `TaskStore.updateArtifact` and the `artifact:updated` SSE event power in-place doc editing in the new ArtifactsGallery (Images/Docs/PDFs/Videos/Audio/Other sections with per-category viewers, mobile-responsive). diff --git a/docs/agents.md b/docs/agents.md index 2aac9b9566..44e170a083 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -44,7 +44,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] Artifact tools operate on the shared artifact registry, so artifacts are visible across agents and tasks when the caller has the artifact ID or can discover it through filters. -- `fn_artifact_register` registers a `document`, `image`, `video`, `audio`, or `other` artifact with `title`, optional `description`, optional `mimeType`, optional inline text `content`, optional `uri`/path reference, and optional `taskId`. Tool callers should provide either inline `content` or a `uri`/path reference for media stored elsewhere. Executor/heartbeat sessions infer the registering agent as `authorId`; dashboard chat uses the `dashboard-chat` author and requires `task_id` because chat has no ambient task. +- `fn_artifact_register` registers a `document`, `image`, `video`, `audio`, or `other` artifact with `title`, optional `description`, optional `mimeType`, and exactly one payload source: inline text `content`, a local file `path` (preferred for media the agent saved to disk — screenshots, wireframes, mockups, recordings; the file is copied into managed artifact storage with MIME inference and image signature validation), base64 `dataBase64` image bytes, or a `uri` reference for media stored elsewhere. Executor sessions resolve relative `path` values against the task worktree and default `taskId` to the executing task; task-scoped heartbeat sessions default `taskId` to the assigned task; dashboard chat uses the `dashboard-chat` author and requires `task_id` because chat has no ambient task. - `fn_artifact_list` lists artifacts across agents and tasks with optional `type`, `authorId`, `taskId`, `search`, `limit`, and `offset` filters. Dashboard chat's scoped variant requires `task_id` and otherwise supports `type`, `authorId`, `search`, `limit`, and `offset` for that task. - `fn_artifact_view` fetches one artifact by `id`, returning registry metadata plus inline `content` when present or the stored `uri`/path reference for media artifacts. - Successful registration emits a best-effort `system` → `user` inbox notification to `DASHBOARD_USER_ID` with `artifactId`, `artifactType`, `title`, `authorId`, and optional `taskId` metadata. Notification delivery failures are logged and must never fail or roll back the artifact registration. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 482d90cd42..a6147ffe81 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -819,11 +819,13 @@ Features: - Browse the **Artifacts** tab for registry media registered by any agent, dashboard chat/user action, or system tool across tasks - Already-open global and task-detail artifact lists refresh live from the artifact registry event when an agent, dashboard chat session, user action, or system tool registers a new artifact, while preserving active search filters and task scoping - Use the tab-count badges to see the current counts for Project Files, Task Documents, and Artifacts; the Artifacts badge reflects the loaded `GET /api/artifacts` result set, including active search filters -- Use the responsive media gallery to scan thumbnail-first image and video cards with consistent framing, while audio, document, and generic artifacts remain readable cards in the same grid -- Expand image and video artifact thumbnails into a full-size lightbox; dismiss it with the close button, backdrop click, or Escape while non-previewable artifact cards keep their normal controls and links -- Preview artifact images inline, play video and audio with native controls, read document previews from inline content/description, and open generic `other` artifacts through their media URL (`GET /api/artifacts/:id/media`) -- Read artifact metadata on each card: type badge (`Image`, `Video`, `Audio`, `Document`, or `Other`), title, optional description/content preview, author ID, timestamp, and linked task title/ID when present -- Use **Open task** on an artifact card to jump back to the originating task when the artifact has a `taskId`; inside task detail, the **Artifacts** tab shows that task's documents and registered media artifacts together +- Browse the category-driven gallery: artifacts are broken down into **Images**, **Docs**, **PDFs**, **Videos**, **Audio**, and **Other** content categories (PDFs are detected by MIME type/extension regardless of registry type). "All" renders one section per present category; the chip row filters to a single category, and chips only appear for categories that exist +- Each category has a tailored experience: Images/Videos use a visual-first tile grid with hover metadata and a full-size lightbox; Docs open a full document viewer with rendered markdown; PDFs open an embedded viewer with an open-in-new-tab action; Audio renders inline player rows; Other renders compact download rows +- **Edit any inline-content doc in place**: the document viewer's **Edit** button switches to an editor whose **Save** persists through `PATCH /api/artifacts/:id` and live-refreshes open galleries via the `artifact:updated` registry event; binary-backed documents stay read-only with a media link +- Dismiss any viewer with the close button, backdrop click, or Escape +- Read artifact metadata on cards, rows, and viewer footers: title, optional description, author ID, timestamp, size, and linked task ID when present +- Use the task link on a card/row or viewer footer to jump back to the originating task when the artifact has a `taskId`; inside task detail, the **Artifacts** tab shows that task's documents and registered media artifacts together +- The gallery scales down at the mobile breakpoint (including landscape phones): category chips scroll horizontally, visual grids collapse to two columns, cards and rows go single-column, and viewers become full-screen sheets - Loading state: the Artifacts tab shows `Loading artifacts…` while the first artifact list request is pending and no artifact results are loaded - Empty states: with no search query it shows `No artifacts yet.` plus the hint that artifacts are created by agents, users, and system tools; with a search query it shows `No artifacts match "".` - Error state: a failed artifact list request uses the shared `Failed to load artifacts: ` panel with a **Retry** action that re-runs the artifact fetch @@ -832,7 +834,9 @@ Features: Agent registrations also surface through the [Mailbox View](#mailbox-view): successful `fn_artifact_register` calls send a best-effort system inbox notification so users can discover new media even before opening the gallery. Artifact list live-refresh does not depend on that best-effort message; it listens to the registry registration event. -![Artifacts view](./screenshots/documents-view.png) +![Artifacts gallery](./screenshots/artifacts-gallery.png) + +![Artifact document viewer with edit mode](./screenshots/artifacts-doc-edit.png) ## Reports View diff --git a/docs/screenshots/artifacts-doc-edit.png b/docs/screenshots/artifacts-doc-edit.png new file mode 100644 index 0000000000..356b0a2ac0 --- /dev/null +++ b/docs/screenshots/artifacts-doc-edit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cf752e605f935e58a6f87d24744a61329d2fb8efdaea58ad0af4bd60744111d +size 146601 diff --git a/docs/screenshots/artifacts-doc-viewer.png b/docs/screenshots/artifacts-doc-viewer.png new file mode 100644 index 0000000000..a7818a0a9f --- /dev/null +++ b/docs/screenshots/artifacts-doc-viewer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6b0fc2cb2711509e64e7ef6ffb3fcba5895ae7169503ed661816e285c486c28 +size 141871 diff --git a/docs/screenshots/artifacts-gallery.png b/docs/screenshots/artifacts-gallery.png new file mode 100644 index 0000000000..88bcbc8b92 --- /dev/null +++ b/docs/screenshots/artifacts-gallery.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d56581556d5c8351034cc32dcf0b81a151720c4bc54d03fe9cdf30beb6c84041 +size 192458 diff --git a/docs/screenshots/artifacts-lightbox.png b/docs/screenshots/artifacts-lightbox.png new file mode 100644 index 0000000000..444af1a1fe --- /dev/null +++ b/docs/screenshots/artifacts-lightbox.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a546fd75b6102dc29a40c49cd56409481f782034833da283d35ca41ed426ccf +size 93188 diff --git a/docs/screenshots/artifacts-mobile.png b/docs/screenshots/artifacts-mobile.png new file mode 100644 index 0000000000..c1d04a37b0 --- /dev/null +++ b/docs/screenshots/artifacts-mobile.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b350855247627f45ec381116e16d01bccd83231cbca3811e96d9cf224e95967 +size 59572 diff --git a/docs/screenshots/artifacts-pdf-viewer.png b/docs/screenshots/artifacts-pdf-viewer.png new file mode 100644 index 0000000000..bb531b2bc0 --- /dev/null +++ b/docs/screenshots/artifacts-pdf-viewer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7edd139230f97b412daf186659bd6352d4e2980a47cadd6dc6aa62d5dbacc2b1 +size 96273 diff --git a/docs/storage.md b/docs/storage.md index cd989b059a..516dfb6bde 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -79,6 +79,8 @@ - Image task attachments (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) are bridged into the artifact registry by `TaskStore.addAttachment()` as `image` rows with `metadata.source: "attachment"` and a relative `attachments/` URI. This keeps one copy of the bytes under `/.fusion/tasks/{ID}/attachments/` while making the image discoverable through artifact list APIs and the Documents/Task Artifacts galleries. Non-image attachments remain attachment-only. Deleting an attachment also deletes its bridged artifact row before removing the attachment file so `/api/artifacts/:id/media` does not point at a deleted attachment. - Inline text/document artifacts may store `content` directly in SQLite and therefore have no media file. The dashboard media route streams `GET /api/artifacts/:id/media` from disk when `uri` is present, accepting task-scoped artifact URIs under `artifacts/` and bridged image-attachment URIs under `attachments/`, or returns inline `content` with the persisted MIME type when no `uri` exists. - `getArtifact(id)` returns metadata by ID, `getArtifacts(taskId)` returns active-task artifacts newest-first, and `listArtifacts(...)` is the cross-agent query path with type/author/task/search filters and pagination. List reads hide artifacts whose parent task is soft-deleted while preserving task-less artifacts. +- `updateArtifact(id, { title?, description?, content? })` powers the dashboard Artifacts view's in-place doc editing (`GET`/`PATCH /api/artifacts/:id`). Content edits are only allowed on inline-content rows (no `uri`); binary-backed rows accept metadata edits only, archived-task artifacts stay read-only, and successful updates emit `artifact:updated` for live gallery refresh. +- `fn_artifact_register` accepts a local file `path` (in addition to inline `content`/`dataBase64`): the tool reads the file (50 MB cap), infers the MIME type from the extension when omitted, signature-validates image payloads (PNG/JPEG/GIF/WebP magic bytes, SVG text sniff), and persists the bytes through `registerArtifact()`'s managed storage path so the registry row keeps a servable URI after worktrees are cleaned up. Executor-lane registrations resolve relative paths against the task worktree and default `taskId` to the executing task. - Task-linked artifact registration requires an active, non-archived task. Archived tasks are read-only for artifact writes; soft-deleted or missing tasks are rejected. - Retention follows the existing task lifecycle rather than a separate artifact policy: soft-deleted parent tasks keep artifact rows/files for forensics but normal live-reader APIs hide them; hard deletion from the active `tasks` table cascades artifact metadata through the `taskId` foreign key, and archive cleanup removes the task directory that contains task-scoped artifact binaries. Task-less artifacts live under `/.fusion/artifacts/` and are not tied to task archival cleanup. - Worktree DB hydration copies task-scoped artifact metadata for the current task/dependency graph alongside task rows and `task_documents`. It intentionally does not copy binary payload files, and it intentionally excludes task-less registry artifacts because dependency hydration is scoped to the active task graph. diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 7cf50f38f3..3702b40313 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -56,7 +56,7 @@ This reference documents tools injected by the engine at runtime for specific ag | `fn_send_message` | executor, step-session, heartbeat | Send inbox messages to agents/users | `to_id` (string), `content` (string), `type?` (`agent-to-agent` \| `agent-to-user`), `reply_to_message_id?` (string) | | `fn_read_messages` | executor, step-session, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) | | `fn_post_room_message` | heartbeat | Post a message to a chat room the agent is a member of | `roomId` (string), `content` (string), `replyToMessageId?` (string), `mentions?` (string[]) | -| `fn_artifact_register` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it; image artifacts may provide `dataBase64` bytes for registry-managed media storage | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `dataBase64?` (base64 string), `taskId?` (string); chat/planning also require `task_id` (string) | +| `fn_artifact_register` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Register an artifact (document, image, video, audio, or other) so it appears in the dashboard Artifacts gallery and other agents and tasks can discover it; media files saved to disk (screenshots, wireframes, mockups, recordings) may provide `path` (copied into registry-managed storage; relative paths resolve against the executor worktree), and image artifacts may alternatively provide `dataBase64` bytes; executor/task-scoped-heartbeat registrations default `taskId` to the executing task | `type` (string), `title` (string), `description?` (string), `mimeType?` (string), `uri?` (string), `content?` (string), `dataBase64?` (base64 string), `path?` (string), `taskId?` (string); chat/planning also require `task_id` (string) | | `fn_artifact_list` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | List registered artifacts across agents and tasks with filters for type, authorId, taskId, search, limit, and offset | `type?` (string), `authorId?` (string), `taskId?` (string), `search?` (string), `limit?` (number), `offset?` (number); chat/planning also require `task_id` (string) | | `fn_artifact_view` | triage, executor, heartbeat | View a registered artifact by id, including metadata and inline content or the uri/path reference for media artifacts | `id` (string) | diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index 2e57dc9bee..5a98fa390f 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -424,6 +424,7 @@ For bug-class/bug-fix tasks, add and fill in the exact \`## Symptom Verification - [ ] Update relevant documentation - [ ] Save documentation deliverables as task documents via \`fn_task_document_write\` (key="docs", content=...) +- [ ] For UI-visible changes or design deliverables: register screenshots/wireframes/mockups as image artifacts via \`fn_artifact_register(type="image", title=..., path="")\` - [ ] Out-of-scope findings created as new tasks via \`fn_task_create\` tool ## Documentation Requirements @@ -578,6 +579,7 @@ For source-free forensic or spec-compliance tasks whose only deliverables are gi - Steps should express OUTCOMES, not micro-instructions (2-5 checkboxes per step) - Always include a testing step and a documentation step - For tasks whose primary deliverable is documentation (updating docs, writing README, API references), include an explicit step or checkbox instructing the executor to save the final documentation content via \`fn_task_document_write\` +- For tasks with a visible UI surface or whose deliverable is visual (wireframes, mockups, designs, diagrams, screenshots), include an explicit step or checkbox instructing the executor to save each visual to disk and register it via \`fn_artifact_register(type="image", title=..., path=...)\` so it appears in the dashboard Artifacts gallery - Include a "Do NOT" section with project-appropriate guardrails - Size assessment: S (<{{triageSizeSmallMaxHours}}h), M ({{triageSizeSmallMaxHours}}-{{triageSizeMediumMaxHours}}h), L ({{triageSizeMediumMaxHours}}-{{triageSizeLargeMaxHours}}h). Split if XL ({{triageSizeLargeMaxHours}}h+) - Review level scoring: Blast radius (0-2), Pattern novelty (0-2), Security (0-2), Reversibility (0-2) diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 1eb18a7abb..dedca2268f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1069,6 +1069,7 @@ export interface TaskStoreEvents { "task:merged": [result: MergeResult]; "settings:updated": [data: { settings: Settings; previous: Settings }]; "artifact:registered": [artifact: Artifact]; + "artifact:updated": [artifact: Artifact]; "agent:log": [entry: AgentLogEntry]; "merger:autostashOrphans": [data: { rootDir: string; @@ -14020,6 +14021,49 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return artifact; } + /** + * FNXC:ArtifactRegistry 2026-07-10-15:20: + * The dashboard Artifacts view lets operators edit any inline-content document artifact in place + * (title/description/content). Binary artifacts (rows with a uri) keep content non-editable because + * their payload lives on disk; only metadata edits are allowed there. Archived-task artifacts stay + * read-only, mirroring registerArtifact. Emits `artifact:updated` and bumps lastModified so open + * artifact lists live-refresh. + */ + async updateArtifact(id: string, updates: { title?: string; description?: string; content?: string }): Promise { + const existing = await this.getArtifact(id); + if (!existing) { + throw new Error(`Artifact ${id} not found`); + } + + if (existing.taskId && this.isTaskArchived(existing.taskId)) { + throw new Error(`Task ${existing.taskId} is archived — artifacts are read-only`); + } + + if (updates.content !== undefined && existing.uri) { + throw new Error(`Artifact ${id} stores a binary payload; its content is not editable`); + } + + const now = new Date().toISOString(); + this.db.prepare( + "UPDATE artifacts SET title = ?, description = ?, content = ?, updatedAt = ? WHERE id = ?", + ).run( + updates.title !== undefined ? updates.title : existing.title, + updates.description !== undefined ? updates.description : existing.description ?? null, + updates.content !== undefined ? updates.content : existing.content ?? null, + now, + id, + ); + + const updated = await this.getArtifact(id); + if (!updated) { + throw new Error(`Failed to update artifact ${id}`); + } + + this.db.bumpLastModified(); + this.emit("artifact:updated", updated); + return updated; + } + /** * FNXC:ArtifactRegistry 2026-06-19-22:04: * Fetch a single artifact metadata row by id for downstream tools and UI without reading binary payload bytes from disk. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 5f6e27dbd7..4befc85161 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1722,6 +1722,28 @@ export function artifactMediaUrl(id: string, projectId?: string): string { return buildApiUrl(withProjectId(`/artifacts/${encodeURIComponent(id)}/media`, projectId)); } +/* +FNXC:ArtifactRegistry 2026-07-10-15:20: +The Artifacts view document viewer needs the full artifact INCLUDING inline content (list responses strip content), and edit mode persists title/description/content through PATCH. +*/ +export async function fetchArtifact(id: string, projectId?: string): Promise { + return api(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId)); +} + +export interface UpdateArtifactInput { + title?: string; + description?: string; + content?: string; +} + +export async function updateArtifact(id: string, updates: UpdateArtifactInput, projectId?: string): Promise { + return api(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); +} + export async function fetchAllDocuments( options?: FetchAllDocumentsOptions, projectId?: string, diff --git a/packages/dashboard/app/components/ArtifactsGallery.css b/packages/dashboard/app/components/ArtifactsGallery.css new file mode 100644 index 0000000000..935e58f720 --- /dev/null +++ b/packages/dashboard/app/components/ArtifactsGallery.css @@ -0,0 +1,544 @@ +/* +FNXC:ArtifactsGallery 2026-07-10-15:40: +Category-driven artifact gallery. Design intent: a shop window for agent-produced work that invites exploration — +visual-first tiles for images/videos with hover metadata, warm reading cards for docs/PDFs, and quiet utility rows +for audio/other. Sections keep the "All" view scannable; chips filter to one category. Everything collapses cleanly +at the mobile breakpoint (max-width: 768px), including short-landscape phones: chips scroll horizontally, visual +grids drop to two columns, cards/rows go single-column, and viewers become full-screen sheets. +*/ + +.artifacts-gallery { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +/* ── Category filter chips ─────────────────────────────────────────── */ + +.artifacts-gallery-filter { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.artifacts-gallery-chip { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + border-radius: var(--radius-pill); +} + +.artifacts-gallery-chip.active { + border-color: var(--todo); + color: var(--text); + background: var(--card-hover); +} + +.artifacts-gallery-chip-count { + border-radius: var(--radius-pill); + padding: 0 var(--space-xs); + background: var(--surface); + font-size: 0.72rem; + font-weight: 600; + color: var(--text-dim); +} + +/* ── Sections ──────────────────────────────────────────────────────── */ + +.artifacts-gallery-section { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.artifacts-gallery-section-header { + display: flex; + align-items: center; + gap: var(--space-xs); + color: var(--text-dim); +} + +.artifacts-gallery-section-header h3 { + margin: 0; + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); +} + +.artifacts-gallery-section-count { + border-radius: var(--radius-pill); + padding: 0 var(--space-xs); + background: var(--surface); + font-size: 0.72rem; + font-weight: 600; +} + +/* ── Visual tiles (images / videos) ───────────────────────────────── */ + +.artifacts-gallery-grid--visual { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-md); +} + +.artifacts-gallery-tile { + overflow: hidden; + border-radius: var(--radius-lg); + border: thin solid var(--border); + background: var(--surface); + transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast); +} + +.artifacts-gallery-tile:hover, +.artifacts-gallery-tile:focus-within { + transform: translateY(calc(-1 * var(--space-xs) / 2)); + border-color: var(--todo); + box-shadow: var(--shadow-lg); +} + +.artifacts-gallery-tile-media { + position: relative; + display: block; + width: 100%; + aspect-ratio: 4 / 3; + cursor: zoom-in; + background: var(--bg); +} + +.artifacts-gallery-tile-media:focus-visible { + outline: none; + box-shadow: inset var(--focus-ring-strong); +} + +.artifacts-gallery-tile-media img, +.artifacts-gallery-tile-media video { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.artifacts-gallery-tile-play { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text); + pointer-events: none; +} + +.artifacts-gallery-tile-play svg { + border-radius: var(--radius-pill); + padding: var(--space-sm); + width: 2.75rem; + height: 2.75rem; + background: var(--surface); + box-shadow: var(--shadow-sm); + opacity: 0.9; +} + +.artifacts-gallery-tile-overlay { + position: absolute; + inset-inline: 0; + bottom: 0; + display: flex; + align-items: baseline; + gap: var(--space-xs); + padding: var(--space-lg) var(--space-sm) var(--space-sm); + background: linear-gradient(to top, color-mix(in srgb, var(--bg) 88%, transparent), transparent); + opacity: 0; + transition: opacity var(--transition-fast); + pointer-events: none; +} + +.artifacts-gallery-tile-media:hover .artifacts-gallery-tile-overlay, +.artifacts-gallery-tile-media:focus-visible .artifacts-gallery-tile-overlay { + opacity: 1; +} + +.artifacts-gallery-tile-title { + overflow: hidden; + min-width: 0; + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.artifacts-gallery-tile-task { + flex-shrink: 0; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-dim); +} + +/* ── Doc / PDF cards ───────────────────────────────────────────────── */ + +.artifacts-gallery-grid--cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); + gap: var(--space-md); +} + +.artifacts-gallery-card { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-md); + border: thin solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + cursor: pointer; + transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); +} + +.artifacts-gallery-card:hover, +.artifacts-gallery-card:focus-visible { + transform: translateY(calc(-1 * var(--space-xs) / 2)); + border-color: var(--todo); + box-shadow: var(--shadow-lg); + background: var(--card-hover); +} + +.artifacts-gallery-card:focus-within { + outline: none; +} + +.artifacts-gallery-card-main { + display: flex; + gap: var(--space-sm); +} + +.artifacts-gallery-card-main:focus-visible { + outline: none; +} + +.artifacts-gallery-card-task { + align-self: flex-start; +} + +.artifacts-gallery-card-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2.75rem; + height: 2.75rem; + border-radius: var(--radius-lg); + background: var(--bg); + color: var(--text-dim); +} + +.artifacts-gallery-card-icon--pdf { + color: var(--color-error); +} + +.artifacts-gallery-card-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.artifacts-gallery-card-title { + margin: 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text); + overflow-wrap: anywhere; +} + +.artifacts-gallery-card-description { + margin: 0; + font-size: 0.8rem; + color: var(--text-dim); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.artifacts-gallery-card-meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + font-size: 0.72rem; + color: var(--text-dim); +} + +/* ── Audio / other rows ────────────────────────────────────────────── */ + +.artifacts-gallery-rows { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.artifacts-gallery-row { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + border: thin solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} + +.artifacts-gallery-row-info { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 0; + flex: 1; + color: var(--text-dim); +} + +.artifacts-gallery-row-title { + overflow: hidden; + min-width: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.artifacts-gallery-row-mime { + flex-shrink: 0; + font-size: 0.7rem; +} + +.artifacts-gallery-row-meta { + flex-shrink: 0; + margin-left: auto; + font-size: 0.72rem; + color: var(--text-dim); +} + +.artifacts-gallery-audio { + width: min(20rem, 40vw); + flex-shrink: 0; +} + +.artifacts-gallery-row-download { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + flex-shrink: 0; +} + +/* ── Task links ────────────────────────────────────────────────────── */ + +.artifacts-gallery-task-link { + border: 0; + padding: 0; + background: none; + font-size: 0.75rem; + font-weight: 600; + color: var(--todo); + cursor: pointer; + text-align: left; +} + +.artifacts-gallery-task-link:hover, +.artifacts-gallery-task-link:focus-visible { + text-decoration: underline; +} + +/* ── Viewers (lightbox / pdf / doc) ────────────────────────────────── */ + +.artifacts-gallery-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-lg); +} + +.artifacts-gallery-viewer { + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: min(46rem, 100%); + max-height: 90dvh; + padding: var(--space-md); + border: thin solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg); + box-shadow: var(--shadow-lg); +} + +.artifacts-gallery-viewer--wide { + width: min(64rem, 100%); +} + +.artifacts-gallery-viewer-header { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.artifacts-gallery-viewer-title { + flex: 1; + min-width: 0; + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text); + overflow-wrap: anywhere; +} + +.artifacts-gallery-viewer-actions { + display: flex; + align-items: center; + gap: var(--space-xs); + flex-shrink: 0; +} + +.artifacts-gallery-viewer-media-frame { + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + overflow: hidden; + border-radius: var(--radius-lg); + background: var(--surface); +} + +.artifacts-gallery-viewer-media { + display: block; + max-width: 100%; + max-height: 65dvh; + object-fit: contain; +} + +.artifacts-gallery-viewer-pdf { + width: 100%; + height: 65dvh; + border: thin solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} + +.artifacts-gallery-viewer-doc { + min-height: 0; + flex: 1; + overflow-y: auto; + padding: var(--space-sm); + border: thin solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} + +.artifacts-gallery-viewer-plain { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.85rem; +} + +/* Edit mode hosts the shared CodeMirror FileEditor; give it a real working height inside the viewer. */ +.artifacts-gallery-viewer-editor { + display: flex; + flex-direction: column; + min-height: 45dvh; + height: 100%; +} + +.artifacts-gallery-viewer-editor .file-editor-container { + flex: 1; + min-height: 40dvh; +} + +.artifacts-gallery-viewer-loading, +.artifacts-gallery-viewer-error { + margin: 0; + font-size: 0.85rem; + color: var(--text-dim); +} + +.artifacts-gallery-viewer-error { + color: var(--color-error); +} + +.artifacts-gallery-viewer-meta { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.artifacts-gallery-viewer-description { + margin: 0; + font-size: 0.82rem; + color: var(--text-dim); +} + +.artifacts-gallery-viewer-meta-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-xs); + font-size: 0.75rem; + color: var(--text-dim); +} + +/* ── Mobile (matches the project mobile MQ incl. landscape phones) ── */ + +@media (max-width: 768px), (max-height: 480px) { + .artifacts-gallery-filter { + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: var(--space-xs); + -webkit-overflow-scrolling: touch; + } + + .artifacts-gallery-chip { + flex-shrink: 0; + } + + .artifacts-gallery-grid--visual { + grid-template-columns: repeat(2, 1fr); + gap: var(--space-sm); + } + + .artifacts-gallery-grid--cards { + grid-template-columns: 1fr; + } + + /* Touch has no hover: keep tile titles always visible on mobile. */ + .artifacts-gallery-tile-overlay { + opacity: 1; + } + + .artifacts-gallery-row { + flex-wrap: wrap; + } + + .artifacts-gallery-audio { + width: 100%; + } + + .artifacts-gallery-overlay { + padding: 0; + } + + .artifacts-gallery-viewer, + .artifacts-gallery-viewer--wide { + width: 100%; + height: 100dvh; + max-height: none; + border: 0; + border-radius: 0; + } + + .artifacts-gallery-viewer-media { + max-height: 70dvh; + } + + .artifacts-gallery-viewer-pdf { + height: 70dvh; + } +} diff --git a/packages/dashboard/app/components/ArtifactsGallery.tsx b/packages/dashboard/app/components/ArtifactsGallery.tsx new file mode 100644 index 0000000000..bbddd343c7 --- /dev/null +++ b/packages/dashboard/app/components/ArtifactsGallery.tsx @@ -0,0 +1,701 @@ +import "./ArtifactsGallery.css"; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { + AudioLines, + Download, + ExternalLink, + FileText, + FileType, + Image as ImageIcon, + Package, + Pencil, + Video, + X, +} from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { Artifact, ArtifactWithTask } from "@fusion/core"; +import type { ToastType } from "../hooks/useToast"; +import { artifactMediaUrl, fetchArtifact, updateArtifact } from "../api"; +import { FileEditor } from "./FileEditor"; + +/* +FNXC:ArtifactRegistry 2026-07-10-15:40: +The Artifacts view is the shop window for agent-produced work (screenshots, wireframes, mockups, docs, recordings), so it breaks artifacts into content categories the operator thinks in — Images, Docs, PDFs, Videos, Audio, Other — instead of raw registry types (PDFs are stored as document/other rows). Each category gets a tailored experience: +- Images/Videos: visual-first tile grid with hover metadata and a full-size lightbox. +- Docs: reading cards that open a full document viewer with rendered markdown and an in-place EDIT mode (any inline-content doc is editable; binary-backed docs stay read-only). +- PDFs: dedicated tiles opening an embedded PDF viewer with an open-in-tab escape hatch. +- Audio: inline player rows. +- Other: compact download rows. +"All" renders sections per present category; chips filter to one. Everything must scale down to the 768px/short-landscape mobile breakpoint: chips scroll horizontally, grids collapse, and viewers go full-screen. +*/ + +export type ArtifactCategory = "image" | "doc" | "pdf" | "video" | "audio" | "other"; +type ArtifactCategoryFilter = ArtifactCategory | "all"; + +export const ARTIFACT_CATEGORY_ORDER: ArtifactCategory[] = ["image", "doc", "pdf", "video", "audio", "other"]; + +export function getArtifactCategory(artifact: Pick): ArtifactCategory { + const mime = artifact.mimeType?.toLowerCase().split(";", 1)[0] ?? ""; + if (mime === "application/pdf" || artifact.uri?.toLowerCase().endsWith(".pdf")) return "pdf"; + if (artifact.type === "image") return "image"; + if (artifact.type === "video") return "video"; + if (artifact.type === "audio") return "audio"; + if (artifact.type === "document") return "doc"; + return "other"; +} + +const CATEGORY_ICONS: Record = { + image: ImageIcon, + doc: FileText, + pdf: FileType, + video: Video, + audio: AudioLines, + other: Package, +}; + +function getCategoryLabel(t: TFunction<"app">, category: ArtifactCategory): string { + switch (category) { + case "image": return t("documents.artifactCategoryImages", "Images"); + case "doc": return t("documents.artifactCategoryDocs", "Docs"); + case "pdf": return t("documents.artifactCategoryPdfs", "PDFs"); + case "video": return t("documents.artifactCategoryVideos", "Videos"); + case "audio": return t("documents.artifactCategoryAudio", "Audio"); + case "other": return t("documents.artifactCategoryOther", "Other"); + } +} + +function formatTimestamp(iso?: string): string { + if (!iso) return ""; + return new Date(iso).toLocaleString(); +} + +function formatFileSize(bytes?: number): string { + if (bytes === undefined) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export interface ArtifactsGalleryProps { + artifacts: ArtifactWithTask[]; + projectId?: string; + isMobile: boolean; + addToast: (message: string, type?: ToastType) => void; + onOpenTask: (taskId: string) => void; + /** Optional callback fired after a successful in-place doc edit so parents can refresh lists. */ + onArtifactUpdated?: () => void; +} + +interface ViewerState { + artifact: ArtifactWithTask; + kind: "media" | "doc" | "pdf"; +} + +export function ArtifactsGallery({ artifacts, projectId, isMobile, addToast, onOpenTask, onArtifactUpdated }: ArtifactsGalleryProps) { + const { t } = useTranslation("app"); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [viewer, setViewer] = useState(null); + const viewerReturnFocusRef = useRef(null); + + const grouped = useMemo(() => { + const groups = new Map(); + for (const artifact of artifacts) { + const category = getArtifactCategory(artifact); + const existing = groups.get(category); + if (existing) { + existing.push(artifact); + } else { + groups.set(category, [artifact]); + } + } + return groups; + }, [artifacts]); + + // A stale filter (its last artifact disappeared via live refresh) falls back to "all" rather than an empty gallery. + useEffect(() => { + if (categoryFilter !== "all" && !grouped.has(categoryFilter)) { + setCategoryFilter("all"); + } + }, [categoryFilter, grouped]); + + const openViewer = useCallback((artifact: ArtifactWithTask) => { + const category = getArtifactCategory(artifact); + const kind: ViewerState["kind"] = category === "doc" ? "doc" : category === "pdf" ? "pdf" : "media"; + viewerReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setViewer({ artifact, kind }); + }, []); + + const closeViewer = useCallback(() => { + setViewer(null); + viewerReturnFocusRef.current?.focus(); + viewerReturnFocusRef.current = null; + }, []); + + const visibleCategories = ARTIFACT_CATEGORY_ORDER.filter((category) => + grouped.has(category) && (categoryFilter === "all" || categoryFilter === category)); + + return ( +
+ {grouped.size > 1 && ( +
+ + {ARTIFACT_CATEGORY_ORDER.filter((category) => grouped.has(category)).map((category) => { + const Icon = CATEGORY_ICONS[category]; + return ( + + ); + })} +
+ )} + + {visibleCategories.map((category) => { + const items = grouped.get(category) ?? []; + const Icon = CATEGORY_ICONS[category]; + return ( +
+ {categoryFilter === "all" && grouped.size > 1 && ( +
+
+ )} + +
+ ); + })} + + {viewer && viewer.kind === "media" && ( + + )} + {viewer && viewer.kind === "pdf" && ( + + )} + {viewer && viewer.kind === "doc" && ( + + )} +
+ ); +} + +interface CategoryGridProps { + category: ArtifactCategory; + items: ArtifactWithTask[]; + projectId?: string; + isMobile: boolean; + t: TFunction<"app">; + onOpen: (artifact: ArtifactWithTask) => void; + onOpenTask: (taskId: string) => void; +} + +function CategoryGrid({ category, items, projectId, isMobile, t, onOpen, onOpenTask }: CategoryGridProps) { + if (category === "audio") { + return ( +
+ {items.map((artifact) => ( + + ))} +
+ ); + } + + if (category === "other") { + return ( +
+ {items.map((artifact) => ( + + ))} +
+ ); + } + + const gridClass = category === "image" || category === "video" + ? "artifacts-gallery-grid artifacts-gallery-grid--visual" + : "artifacts-gallery-grid artifacts-gallery-grid--cards"; + + return ( +
+ {items.map((artifact) => ( + category === "image" || category === "video" + ? + : + ))} +
+ ); +} + +interface TileProps { + artifact: ArtifactWithTask; + category: ArtifactCategory; + projectId?: string; + t: TFunction<"app">; + onOpen: (artifact: ArtifactWithTask) => void; +} + +function VisualTile({ artifact, category, projectId, t, onOpen }: TileProps) { + const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); + const mediaUrl = artifactMediaUrl(artifact.id, projectId); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(artifact); + } + }; + + return ( +
+
onOpen(artifact)} + onKeyDown={handleKeyDown} + > + {category === "image" ? ( + {title} + ) : ( +
+
+ ); +} + +interface DocCardProps { + artifact: ArtifactWithTask; + category: ArtifactCategory; + t: TFunction<"app">; + onOpen: (artifact: ArtifactWithTask) => void; + onOpenTask: (taskId: string) => void; +} + +function DocCard({ artifact, category, t, onOpen, onOpenTask }: DocCardProps) { + const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); + const Icon = CATEGORY_ICONS[category]; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(artifact); + } + }; + + return ( +
+
onOpen(artifact)} + onKeyDown={handleKeyDown} + > +
+
+
+

{title}

+ {artifact.description &&

{artifact.description}

} +
+ {artifact.authorId} + · + {formatTimestamp(artifact.createdAt)} + {artifact.sizeBytes !== undefined && · {formatFileSize(artifact.sizeBytes)}} +
+
+
+ {artifact.taskId && ( + + )} +
+ ); +} + +interface RowProps { + artifact: ArtifactWithTask; + projectId?: string; + t: TFunction<"app">; + onOpenTask: (taskId: string) => void; +} + +function AudioRow({ artifact, projectId, t, onOpenTask }: RowProps) { + const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); + return ( +
+
+
+
+ ); +} + +function FileRow({ artifact, projectId, t, onOpenTask }: RowProps) { + const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); + return ( +
+
+
+ + +
+ ); +} + +interface OverlayProps { + artifact: ArtifactWithTask; + projectId?: string; + t: TFunction<"app">; + onClose: () => void; + onOpenTask: (taskId: string) => void; +} + +function useOverlayDismiss(onClose: () => void, closeRef: React.RefObject) { + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + closeRef.current?.focus(); + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", handleKeyDown); + }; + }, [closeRef, onClose]); +} + +/* +FNXC:ArtifactsGallery 2026-07-10-15:40: +Each viewer owns its close-button ref and passes it to both OverlayShell (Escape/scroll-lock lifecycle + autofocus so keyboard users land on a dismiss affordance) and ViewerHeader (which renders the actual button). +*/ +function OverlayShell({ label, onClose, children, wide, closeRef }: { label: string; onClose: () => void; children: React.ReactNode; wide?: boolean; closeRef: React.RefObject }) { + useOverlayDismiss(onClose, closeRef); + + const handleOverlayClick = (event: MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + return ( +
+
event.stopPropagation()}> + {children} +
+
+ ); +} + +function ViewerHeader({ title, onClose, t, actions, closeRef }: { title: string; onClose: () => void; t: TFunction<"app">; actions?: React.ReactNode; closeRef: React.RefObject }) { + return ( +
+

{title}

+
+ {actions} + +
+
+ ); +} + +function ViewerMeta({ artifact, t, onOpenTask }: { artifact: ArtifactWithTask; t: TFunction<"app">; onOpenTask: (taskId: string) => void }) { + return ( +
+ {artifact.description &&

{artifact.description}

} +
+ {artifact.authorId} + · + {formatTimestamp(artifact.createdAt)} + {artifact.sizeBytes !== undefined && · {formatFileSize(artifact.sizeBytes)}} + {artifact.taskId && ( + + )} +
+
+ ); +} + +function MediaLightbox({ artifact, projectId, t, onClose, onOpenTask }: OverlayProps) { + const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); + const mediaUrl = artifactMediaUrl(artifact.id, projectId); + const closeRef = useRef(null); + + return ( + + + + ); +} + +function PdfViewer({ artifact, projectId, t, onClose, onOpenTask }: OverlayProps) { + const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact"); + const mediaUrl = artifactMediaUrl(artifact.id, projectId); + const closeRef = useRef(null); + + return ( + + +