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 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/artifact-pipeline-and-gallery.md
Normal file
7
.changeset/artifact-pipeline-and-gallery.md
Normal file
@@ -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).
|
||||
@@ -44,7 +44,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 "<query>".`
|
||||
- Error state: a failed artifact list request uses the shared `Failed to load artifacts: <error>` 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.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## Reports View
|
||||
|
||||
|
||||
BIN
docs/screenshots/artifacts-doc-edit.png
LFS
Normal file
BIN
docs/screenshots/artifacts-doc-edit.png
LFS
Normal file
Binary file not shown.
3
docs/screenshots/artifacts-doc-viewer.png
Normal file
3
docs/screenshots/artifacts-doc-viewer.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a6b0fc2cb2711509e64e7ef6ffb3fcba5895ae7169503ed661816e285c486c28
|
||||
size 141871
|
||||
3
docs/screenshots/artifacts-gallery.png
Normal file
3
docs/screenshots/artifacts-gallery.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d56581556d5c8351034cc32dcf0b81a151720c4bc54d03fe9cdf30beb6c84041
|
||||
size 192458
|
||||
BIN
docs/screenshots/artifacts-lightbox.png
LFS
Normal file
BIN
docs/screenshots/artifacts-lightbox.png
LFS
Normal file
Binary file not shown.
3
docs/screenshots/artifacts-mobile.png
Normal file
3
docs/screenshots/artifacts-mobile.png
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2b350855247627f45ec381116e16d01bccd83231cbca3811e96d9cf224e95967
|
||||
size 59572
|
||||
BIN
docs/screenshots/artifacts-pdf-viewer.png
LFS
Normal file
BIN
docs/screenshots/artifacts-pdf-viewer.png
LFS
Normal file
Binary file not shown.
@@ -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/<file>` URI. This keeps one copy of the bytes under `<rootDir>/.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 `<rootDir>/.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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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="<saved file>")\`
|
||||
- [ ] 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)
|
||||
|
||||
@@ -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<Artifact> {
|
||||
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.
|
||||
|
||||
@@ -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<Artifact> {
|
||||
return api<Artifact>(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<Artifact> {
|
||||
return api<Artifact>(withProjectId(`/artifacts/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAllDocuments(
|
||||
options?: FetchAllDocumentsOptions,
|
||||
projectId?: string,
|
||||
|
||||
544
packages/dashboard/app/components/ArtifactsGallery.css
Normal file
544
packages/dashboard/app/components/ArtifactsGallery.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
701
packages/dashboard/app/components/ArtifactsGallery.tsx
Normal file
701
packages/dashboard/app/components/ArtifactsGallery.tsx
Normal file
@@ -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<ArtifactWithTask, "type" | "mimeType" | "uri">): 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<ArtifactCategory, typeof ImageIcon> = {
|
||||
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<ArtifactCategoryFilter>("all");
|
||||
const [viewer, setViewer] = useState<ViewerState | null>(null);
|
||||
const viewerReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<ArtifactCategory, ArtifactWithTask[]>();
|
||||
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 (
|
||||
<div className="artifacts-gallery">
|
||||
{grouped.size > 1 && (
|
||||
<div className="artifacts-gallery-filter" role="group" aria-label={t("documents.artifactTypeFilterLabel", "Filter artifacts by category")}>
|
||||
<button
|
||||
className={`btn btn-sm artifacts-gallery-chip${categoryFilter === "all" ? " active" : ""}`}
|
||||
aria-pressed={categoryFilter === "all"}
|
||||
onClick={() => setCategoryFilter("all")}
|
||||
>
|
||||
{t("documents.artifactFilterAll", "All")}
|
||||
<span className="artifacts-gallery-chip-count">{artifacts.length}</span>
|
||||
</button>
|
||||
{ARTIFACT_CATEGORY_ORDER.filter((category) => grouped.has(category)).map((category) => {
|
||||
const Icon = CATEGORY_ICONS[category];
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
className={`btn btn-sm artifacts-gallery-chip${categoryFilter === category ? " active" : ""}`}
|
||||
aria-pressed={categoryFilter === category}
|
||||
onClick={() => setCategoryFilter(category)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
{getCategoryLabel(t, category)}
|
||||
<span className="artifacts-gallery-chip-count">{grouped.get(category)?.length}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleCategories.map((category) => {
|
||||
const items = grouped.get(category) ?? [];
|
||||
const Icon = CATEGORY_ICONS[category];
|
||||
return (
|
||||
<section key={category} className="artifacts-gallery-section" aria-label={getCategoryLabel(t, category)}>
|
||||
{categoryFilter === "all" && grouped.size > 1 && (
|
||||
<header className="artifacts-gallery-section-header">
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<h3>{getCategoryLabel(t, category)}</h3>
|
||||
<span className="artifacts-gallery-section-count">{items.length}</span>
|
||||
</header>
|
||||
)}
|
||||
<CategoryGrid
|
||||
category={category}
|
||||
items={items}
|
||||
projectId={projectId}
|
||||
isMobile={isMobile}
|
||||
t={t}
|
||||
onOpen={openViewer}
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{viewer && viewer.kind === "media" && (
|
||||
<MediaLightbox artifact={viewer.artifact} projectId={projectId} t={t} onClose={closeViewer} onOpenTask={onOpenTask} />
|
||||
)}
|
||||
{viewer && viewer.kind === "pdf" && (
|
||||
<PdfViewer artifact={viewer.artifact} projectId={projectId} t={t} onClose={closeViewer} onOpenTask={onOpenTask} />
|
||||
)}
|
||||
{viewer && viewer.kind === "doc" && (
|
||||
<DocViewer
|
||||
artifact={viewer.artifact}
|
||||
projectId={projectId}
|
||||
t={t}
|
||||
addToast={addToast}
|
||||
onClose={closeViewer}
|
||||
onOpenTask={onOpenTask}
|
||||
onArtifactUpdated={onArtifactUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="artifacts-gallery-rows">
|
||||
{items.map((artifact) => (
|
||||
<AudioRow key={artifact.id} artifact={artifact} projectId={projectId} t={t} onOpenTask={onOpenTask} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (category === "other") {
|
||||
return (
|
||||
<div className="artifacts-gallery-rows">
|
||||
{items.map((artifact) => (
|
||||
<FileRow key={artifact.id} artifact={artifact} projectId={projectId} t={t} onOpenTask={onOpenTask} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridClass = category === "image" || category === "video"
|
||||
? "artifacts-gallery-grid artifacts-gallery-grid--visual"
|
||||
: "artifacts-gallery-grid artifacts-gallery-grid--cards";
|
||||
|
||||
return (
|
||||
<div className={`${gridClass}${isMobile ? " artifacts-gallery-grid--mobile" : ""}`}>
|
||||
{items.map((artifact) => (
|
||||
category === "image" || category === "video"
|
||||
? <VisualTile key={artifact.id} artifact={artifact} category={category} projectId={projectId} t={t} onOpen={onOpen} />
|
||||
: <DocCard key={artifact.id} artifact={artifact} category={category} t={t} onOpen={onOpen} onOpenTask={onOpenTask} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(artifact);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="artifacts-gallery-tile" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||
<div
|
||||
className="artifacts-gallery-tile-media"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("documents.expandArtifact", "Expand {{title}}", { title })}
|
||||
onClick={() => onOpen(artifact)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{category === "image" ? (
|
||||
<img src={mediaUrl} alt={title} loading="lazy" />
|
||||
) : (
|
||||
<video src={mediaUrl} muted preload="metadata" aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />
|
||||
)}
|
||||
{category === "video" && <span className="artifacts-gallery-tile-play" aria-hidden="true"><Video size={22} /></span>}
|
||||
<div className="artifacts-gallery-tile-overlay">
|
||||
<span className="artifacts-gallery-tile-title">{title}</span>
|
||||
{artifact.taskId && <span className="artifacts-gallery-tile-task">{artifact.taskId}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(artifact);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="artifacts-gallery-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||
<div
|
||||
className="artifacts-gallery-card-main"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("documents.openArtifactViewer", "Open {{title}}", { title })}
|
||||
onClick={() => onOpen(artifact)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className={`artifacts-gallery-card-icon artifacts-gallery-card-icon--${category}`}>
|
||||
<Icon size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="artifacts-gallery-card-body">
|
||||
<h4 className="artifacts-gallery-card-title">{title}</h4>
|
||||
{artifact.description && <p className="artifacts-gallery-card-description">{artifact.description}</p>}
|
||||
<div className="artifacts-gallery-card-meta">
|
||||
<span>{artifact.authorId}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTimestamp(artifact.createdAt)}</span>
|
||||
{artifact.sizeBytes !== undefined && <span>· {formatFileSize(artifact.sizeBytes)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{artifact.taskId && (
|
||||
<button
|
||||
className="artifacts-gallery-task-link artifacts-gallery-card-task"
|
||||
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") })}
|
||||
>
|
||||
{artifact.taskId}
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<article className="artifacts-gallery-row" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||
<div className="artifacts-gallery-row-info">
|
||||
<AudioLines size={16} aria-hidden="true" />
|
||||
<span className="artifacts-gallery-row-title">{title}</span>
|
||||
{artifact.taskId && (
|
||||
<button className="artifacts-gallery-task-link" onClick={() => onOpenTask(artifact.taskId as string)}>
|
||||
{artifact.taskId}
|
||||
</button>
|
||||
)}
|
||||
<span className="artifacts-gallery-row-meta">{formatTimestamp(artifact.createdAt)}</span>
|
||||
</div>
|
||||
<audio className="artifacts-gallery-audio" controls src={artifactMediaUrl(artifact.id, projectId)} aria-label={t("documents.artifactAudioLabel", "Audio artifact: {{title}}", { title })} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ artifact, projectId, t, onOpenTask }: RowProps) {
|
||||
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
|
||||
return (
|
||||
<article className="artifacts-gallery-row" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||
<div className="artifacts-gallery-row-info">
|
||||
<Package size={16} aria-hidden="true" />
|
||||
<span className="artifacts-gallery-row-title">{title}</span>
|
||||
{artifact.mimeType && <span className="artifacts-gallery-row-mime badge">{artifact.mimeType}</span>}
|
||||
{artifact.taskId && (
|
||||
<button className="artifacts-gallery-task-link" onClick={() => onOpenTask(artifact.taskId as string)}>
|
||||
{artifact.taskId}
|
||||
</button>
|
||||
)}
|
||||
<span className="artifacts-gallery-row-meta">
|
||||
{formatFileSize(artifact.sizeBytes)}{artifact.sizeBytes !== undefined ? " · " : ""}{formatTimestamp(artifact.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
className="btn btn-sm artifacts-gallery-row-download"
|
||||
href={artifactMediaUrl(artifact.id, projectId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="artifact-other-link"
|
||||
aria-label={t("documents.downloadArtifactAria", "Download {{title}}", { title })}
|
||||
>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
{t("documents.downloadArtifact", "Download")}
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverlayProps {
|
||||
artifact: ArtifactWithTask;
|
||||
projectId?: string;
|
||||
t: TFunction<"app">;
|
||||
onClose: () => void;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function useOverlayDismiss(onClose: () => void, closeRef: React.RefObject<HTMLButtonElement | null>) {
|
||||
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<HTMLButtonElement | null> }) {
|
||||
useOverlayDismiss(onClose, closeRef);
|
||||
|
||||
const handleOverlayClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open artifacts-gallery-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className={`artifacts-gallery-viewer${wide ? " artifacts-gallery-viewer--wide" : ""}`} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerHeader({ title, onClose, t, actions, closeRef }: { title: string; onClose: () => void; t: TFunction<"app">; actions?: React.ReactNode; closeRef: React.RefObject<HTMLButtonElement | null> }) {
|
||||
return (
|
||||
<div className="artifacts-gallery-viewer-header">
|
||||
<h3 className="artifacts-gallery-viewer-title">{title}</h3>
|
||||
<div className="artifacts-gallery-viewer-actions">
|
||||
{actions}
|
||||
<button ref={closeRef} className="modal-close" onClick={onClose} aria-label={t("documents.closeLightbox", "Close artifact preview")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerMeta({ artifact, t, onOpenTask }: { artifact: ArtifactWithTask; t: TFunction<"app">; onOpenTask: (taskId: string) => void }) {
|
||||
return (
|
||||
<div className="artifacts-gallery-viewer-meta">
|
||||
{artifact.description && <p className="artifacts-gallery-viewer-description">{artifact.description}</p>}
|
||||
<div className="artifacts-gallery-viewer-meta-row">
|
||||
<span>{artifact.authorId}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTimestamp(artifact.createdAt)}</span>
|
||||
{artifact.sizeBytes !== undefined && <span>· {formatFileSize(artifact.sizeBytes)}</span>}
|
||||
{artifact.taskId && (
|
||||
<button className="artifacts-gallery-task-link" onClick={() => onOpenTask(artifact.taskId as string)}>
|
||||
{t("documents.openTask", "Open task")} {artifact.taskId}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<OverlayShell label={t("documents.lightboxLabel", "Artifact media preview")} onClose={onClose} wide closeRef={closeRef}>
|
||||
<ViewerHeader
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
t={t}
|
||||
closeRef={closeRef}
|
||||
actions={(
|
||||
<a className="btn btn-sm" href={mediaUrl} target="_blank" rel="noreferrer" aria-label={t("documents.openInNewTab", "Open in new tab")}>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
<div className="artifacts-gallery-viewer-media-frame">
|
||||
{artifact.type === "image" ? (
|
||||
<img className="artifacts-gallery-viewer-media" src={mediaUrl} alt={title} />
|
||||
) : (
|
||||
<video
|
||||
className="artifacts-gallery-viewer-media"
|
||||
src={mediaUrl}
|
||||
controls
|
||||
autoPlay
|
||||
aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ViewerMeta artifact={artifact} t={t} onOpenTask={onOpenTask} />
|
||||
</OverlayShell>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<OverlayShell label={t("documents.pdfViewerLabel", "PDF artifact viewer")} onClose={onClose} wide closeRef={closeRef}>
|
||||
<ViewerHeader
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
t={t}
|
||||
closeRef={closeRef}
|
||||
actions={(
|
||||
<a className="btn btn-sm" href={mediaUrl} target="_blank" rel="noreferrer" aria-label={t("documents.openInNewTab", "Open in new tab")}>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
{t("documents.openInNewTab", "Open in new tab")}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
<iframe className="artifacts-gallery-viewer-pdf" src={mediaUrl} title={title} />
|
||||
<ViewerMeta artifact={artifact} t={t} onOpenTask={onOpenTask} />
|
||||
</OverlayShell>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocViewerProps extends OverlayProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onArtifactUpdated?: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-10-16:30:
|
||||
Any inline-content doc artifact must be editable straight from the Artifacts view (operator requirement: "jump into edit mode for any doc", "with regular file editor control and view"). The viewer fetches the full artifact (list responses strip content) and renders MARKDOWN BY DEFAULT so docs look polished on open. Edit mode embeds the same CodeMirror FileEditor used for workspace files (syntax highlighting, Edit/Preview toolbar for markdown, word wrap), and Save persists via PATCH /artifacts/:id. Binary-backed docs (rows with a uri) hide Edit because their payload lives on disk. The synthetic filePath maps the artifact MIME type to an extension so FileEditor picks the right language mode.
|
||||
*/
|
||||
function artifactEditorFileName(artifact: Artifact): string {
|
||||
const mime = artifact.mimeType?.toLowerCase().split(";", 1)[0] ?? "";
|
||||
const extension = mime === "text/html" ? ".html"
|
||||
: mime === "application/json" ? ".json"
|
||||
: mime === "text/plain" ? ".txt"
|
||||
: ".md";
|
||||
return `${(artifact.title || "artifact").replace(/[^\w.-]+/g, "-")}${extension}`;
|
||||
}
|
||||
|
||||
function DocViewer({ artifact, projectId, t, addToast, onClose, onOpenTask, onArtifactUpdated }: DocViewerProps) {
|
||||
const title = artifact.title || t("documents.untitledArtifact", "Untitled artifact");
|
||||
const [detail, setDetail] = useState<Artifact | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(true);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchArtifact(artifact.id, projectId)
|
||||
.then((fetched) => {
|
||||
if (!cancelled) setDetail(fetched);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [artifact.id, projectId]);
|
||||
|
||||
const editable = detail !== null && !detail.uri;
|
||||
const content = detail?.content ?? "";
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(content);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateArtifact(artifact.id, { content: draft }, projectId);
|
||||
setDetail(updated);
|
||||
setEditing(false);
|
||||
addToast(t("documents.artifactSaved", "Artifact saved"), "success");
|
||||
onArtifactUpdated?.();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : String(err), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OverlayShell label={t("documents.docViewerLabel", "Document artifact viewer")} onClose={onClose} wide closeRef={closeRef}>
|
||||
<ViewerHeader
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
t={t}
|
||||
closeRef={closeRef}
|
||||
actions={editing ? (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={() => setEditing(false)} disabled={saving}>
|
||||
{t("documents.cancelEdit", "Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleSave()} disabled={saving}>
|
||||
{saving ? t("documents.saving", "Saving…") : t("documents.saveArtifact", "Save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
aria-pressed={renderMarkdown}
|
||||
title={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
>
|
||||
{renderMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
|
||||
</button>
|
||||
{editable && (
|
||||
<button className="btn btn-sm" onClick={startEditing} aria-label={t("documents.editArtifact", "Edit document")}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
{t("documents.edit", "Edit")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="artifacts-gallery-viewer-doc">
|
||||
{loadError ? (
|
||||
<p className="artifacts-gallery-viewer-error">{loadError}</p>
|
||||
) : detail === null ? (
|
||||
<p className="artifacts-gallery-viewer-loading">{t("documents.loadingArtifact", "Loading artifact…")}</p>
|
||||
) : editing ? (
|
||||
<div className="artifacts-gallery-viewer-editor" aria-label={t("documents.artifactContentEditor", "Artifact content editor")}>
|
||||
<FileEditor
|
||||
content={draft}
|
||||
onChange={setDraft}
|
||||
filePath={artifactEditorFileName(detail)}
|
||||
forceToolbarActionsVisible
|
||||
/>
|
||||
</div>
|
||||
) : detail.uri ? (
|
||||
<p className="artifacts-gallery-viewer-loading">
|
||||
{t("documents.binaryDocArtifact", "This document is stored as a file.")}{" "}
|
||||
<a href={artifactMediaUrl(artifact.id, projectId)} target="_blank" rel="noreferrer">
|
||||
{t("documents.openArtifactMedia", "Open artifact media")}
|
||||
</a>
|
||||
</p>
|
||||
) : renderMarkdown ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="artifacts-gallery-viewer-plain">{content}</pre>
|
||||
)}
|
||||
</div>
|
||||
<ViewerMeta artifact={artifact} t={t} onOpenTask={onOpenTask} />
|
||||
</OverlayShell>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import "./DocumentsView.css";
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent, type KeyboardEvent, type MouseEvent } from "react";
|
||||
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 ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ArtifactWithTask, ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ColumnId, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { artifactMediaUrl, fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { 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";
|
||||
import { SelectionCommentPopover } from "./SelectionCommentPopover";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia";
|
||||
import { ArtifactsGallery } from "./ArtifactsGallery";
|
||||
import { ViewHeader } from "./ViewHeader";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
|
||||
@@ -45,13 +45,6 @@ interface TaskGroupProps {
|
||||
onToggleMarkdown: (docId: string) => void;
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
artifact: ArtifactWithTask;
|
||||
projectId?: string;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
onExpandMedia: (artifact: ArtifactWithTask) => void;
|
||||
}
|
||||
|
||||
function formatTimestamp(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString();
|
||||
@@ -206,68 +199,6 @@ function TaskGroup({ taskId, taskTitle, documents, taskColumn, onOpenTask, rende
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactCard({ artifact, projectId, onOpenTask, onExpandMedia }: 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 isExpandableMedia = artifact.type === "image" || artifact.type === "video";
|
||||
const handleExpandKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onExpandMedia(artifact);
|
||||
}
|
||||
}, [artifact, onExpandMedia]);
|
||||
|
||||
return (
|
||||
<article className="document-card documents-artifact-card" aria-label={t("documents.artifactCardLabel", "Artifact {{title}}", { title })}>
|
||||
{isExpandableMedia ? (
|
||||
<div
|
||||
className="documents-artifact-preview documents-artifact-preview--expandable"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("documents.expandArtifact", "Expand {{title}}", { title })}
|
||||
onClick={() => onExpandMedia(artifact)}
|
||||
onKeyDown={handleExpandKeyDown}
|
||||
>
|
||||
{artifact.type === "image" ? (
|
||||
<img className="documents-artifact-media" src={mediaUrl} alt={title} loading="lazy" />
|
||||
) : (
|
||||
<video className="documents-artifact-media" src={mediaUrl} muted preload="metadata" aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title })} />
|
||||
)}
|
||||
<span className="documents-artifact-expand-hint">{t("documents.expandArtifactHint", "Click to expand")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="documents-artifact-preview">
|
||||
<ArtifactMedia artifact={artifact} mediaUrl={mediaUrl} title={title} preview={preview} t={t} />
|
||||
</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, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
|
||||
@@ -286,13 +217,6 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false);
|
||||
// Markdown render toggles per task document card (scoped by doc ID)
|
||||
const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState<Map<string, boolean>>(new Map());
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-06-21-23:22:
|
||||
Image and video artifacts open in a dismissible lightbox, but audio, document, and generic artifacts remain normal cards so non-previewable media never receive orphaned expand targets.
|
||||
*/
|
||||
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactWithTask | null>(null);
|
||||
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const [selectionCommentOpen, setSelectionCommentOpen] = useState(false);
|
||||
const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen });
|
||||
const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen });
|
||||
@@ -352,7 +276,6 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
setFileLoading(false);
|
||||
setRenderProjectMarkdown(false);
|
||||
setTaskDocMarkdownStates(new Map());
|
||||
setLightboxArtifact(null);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -507,46 +430,6 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpandArtifact = useCallback((artifact: ArtifactWithTask) => {
|
||||
lightboxReturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setLightboxArtifact(artifact);
|
||||
}, []);
|
||||
|
||||
const handleCloseLightbox = useCallback(() => {
|
||||
setLightboxArtifact(null);
|
||||
lightboxReturnFocusRef.current?.focus();
|
||||
lightboxReturnFocusRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightboxArtifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
lightboxCloseRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleCloseLightbox();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCloseLightbox, lightboxArtifact]);
|
||||
|
||||
const handleLightboxOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
handleCloseLightbox();
|
||||
}
|
||||
}, [handleCloseLightbox]);
|
||||
|
||||
const activeError = activeTab === "project" ? projectFilesError : activeTab === "tasks" ? documentsError : artifactsError;
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
@@ -798,27 +681,24 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
<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.")}
|
||||
{t("documents.artifactsCreatedBy", "Agents register screenshots, wireframes, mockups, recordings, and documents here as they work on tasks.")}
|
||||
</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.
|
||||
FNXC:ArtifactRegistry 2026-07-10-15:40:
|
||||
The Artifacts tab delegates to ArtifactsGallery: a category-driven surface (Images, Docs, PDFs, Videos, Audio, Other) with a tailored viewer per category, including an editable full document viewer for inline-content docs.
|
||||
*/
|
||||
<div className={`documents-artifact-gallery${isMobile ? " documents-artifact-gallery--mobile" : ""}`}>
|
||||
{artifacts.map((artifact) => (
|
||||
<ArtifactCard
|
||||
key={artifact.id}
|
||||
artifact={artifact}
|
||||
projectId={projectId}
|
||||
onOpenTask={handleOpenArtifactTask}
|
||||
onExpandMedia={handleExpandArtifact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ArtifactsGallery
|
||||
artifacts={artifacts}
|
||||
projectId={projectId}
|
||||
isMobile={isMobile}
|
||||
addToast={addToast}
|
||||
onOpenTask={handleOpenArtifactTask}
|
||||
onArtifactUpdated={() => void refreshArtifacts()}
|
||||
/>
|
||||
)
|
||||
) : documentsLoading && documents.length === 0 ? (
|
||||
<div className="documents-view-loading">
|
||||
@@ -857,42 +737,6 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifac
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lightboxArtifact && (
|
||||
<div
|
||||
className="modal-overlay open documents-artifact-lightbox-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("documents.lightboxLabel", "Artifact media preview")}
|
||||
onClick={handleLightboxOverlayClick}
|
||||
>
|
||||
{/* FNXC:ArtifactRegistry 2026-06-21-23:22: The lightbox reuses the shared modal overlay pattern so image/video artifacts can expand full-size and dismiss by close button, backdrop, or Escape on desktop and mobile. */}
|
||||
<div className="documents-artifact-lightbox" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="documents-artifact-lightbox-header">
|
||||
<h3 className="documents-artifact-lightbox-title">{lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}</h3>
|
||||
<button ref={lightboxCloseRef} className="modal-close" onClick={handleCloseLightbox} aria-label={t("documents.closeLightbox", "Close artifact preview")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="documents-artifact-lightbox-media-frame">
|
||||
{lightboxArtifact.type === "image" ? (
|
||||
<img
|
||||
className="documents-artifact-lightbox-media"
|
||||
src={artifactMediaUrl(lightboxArtifact.id, projectId)}
|
||||
alt={lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact")}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
className="documents-artifact-lightbox-media"
|
||||
src={artifactMediaUrl(lightboxArtifact.id, projectId)}
|
||||
controls
|
||||
autoPlay
|
||||
aria-label={t("documents.artifactVideoLabel", "Video artifact: {{title}}", { title: lightboxArtifact.title || t("documents.untitledArtifact", "Untitled artifact") })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import type { ArtifactWithTask, TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import { DocumentsView } from "../DocumentsView";
|
||||
import { fetchTaskDetail, fetchWorkspaceFileContent } from "../../api";
|
||||
import { fetchArtifact, fetchTaskDetail, fetchWorkspaceFileContent, updateArtifact } from "../../api";
|
||||
import { useArtifacts } from "../../hooks/useArtifacts";
|
||||
import { useDocuments } from "../../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../../hooks/useProjectMarkdownFiles";
|
||||
@@ -13,9 +13,21 @@ vi.mock("../../api", () => ({
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
fetchArtifacts: vi.fn(),
|
||||
fetchArtifact: vi.fn(),
|
||||
updateArtifact: vi.fn(),
|
||||
artifactMediaUrl: vi.fn((id: string) => `/api/artifacts/${id}/media`),
|
||||
}));
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-10-16:30:
|
||||
The artifact doc editor embeds the shared CodeMirror FileEditor, which cannot run meaningfully in jsdom; a textarea shim preserves the content/onChange contract under test.
|
||||
*/
|
||||
vi.mock("../FileEditor", () => ({
|
||||
FileEditor: ({ content, onChange }: { content: string; onChange: (value: string) => void }) => (
|
||||
<textarea aria-label="file editor" value={content} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDocuments", () => ({
|
||||
useDocuments: vi.fn(),
|
||||
}));
|
||||
@@ -33,6 +45,8 @@ const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||
const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
|
||||
const mockFetchArtifact = vi.mocked(fetchArtifact);
|
||||
const mockUpdateArtifact = vi.mocked(updateArtifact);
|
||||
|
||||
function mockSelectionRect() {
|
||||
const rect = new DOMRect(10, 20, 80, 12);
|
||||
@@ -452,23 +466,35 @@ describe("DocumentsView", () => {
|
||||
expect(screen.getByRole("button", { name: "Expand Video artifact" })).toBeInTheDocument();
|
||||
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();
|
||||
|
||||
// Category chips render for every present category with counts (All = total).
|
||||
const filter = screen.getByRole("group", { name: /filter artifacts by category/i });
|
||||
expect(within(filter).getByRole("button", { name: /all\s*5/i })).toBeInTheDocument();
|
||||
for (const chip of ["Images", "Docs", "Videos", "Audio", "Other"]) {
|
||||
expect(within(filter).getByRole("button", { name: new RegExp(`${chip}\\s*1`, "i") })).toBeInTheDocument();
|
||||
}
|
||||
|
||||
for (const title of ["Audio artifact", "Document artifact", "Other artifact"]) {
|
||||
const card = screen.getByRole("article", { name: `Artifact ${title}` });
|
||||
expect(within(card).queryByRole("button", { name: `Expand ${title}` })).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /open task KB-001/i }));
|
||||
// A category chip filters the gallery down to that category.
|
||||
fireEvent.click(within(filter).getByRole("button", { name: /docs\s*1/i }));
|
||||
expect(screen.queryByRole("img", { name: "Image artifact" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("article", { name: "Artifact Document artifact" })).toBeInTheDocument();
|
||||
fireEvent.click(within(filter).getByRole("button", { name: /all\s*5/i }));
|
||||
|
||||
// Opening the image lightbox exposes its task link, which opens through the artifact task path.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand Image artifact" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /open task/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTaskDetail).toHaveBeenCalledWith("KB-001", undefined);
|
||||
expect(onOpenArtifactTaskDetail).toHaveBeenCalledWith({ id: "KB-001" });
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByRole("button", { name: /open task/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens and dismisses the image and video artifact lightbox by click keyboard close backdrop and escape", () => {
|
||||
@@ -500,7 +526,7 @@ describe("DocumentsView", () => {
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Expand Video artifact" }), { key: " " });
|
||||
dialog = screen.getByRole("dialog", { name: "Artifact media preview" });
|
||||
expect(within(dialog).getByLabelText("Video artifact: Video artifact").tagName).toBe("VIDEO");
|
||||
expect(container.querySelector(".documents-artifact-lightbox-media-frame video")).toHaveAttribute("controls");
|
||||
expect(container.querySelector(".artifacts-gallery-viewer-media-frame video")).toHaveAttribute("controls");
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "Artifact media preview" })).not.toBeInTheDocument();
|
||||
expect(document.body.style.overflow).toBe("");
|
||||
@@ -552,7 +578,43 @@ describe("DocumentsView", () => {
|
||||
});
|
||||
rerender(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
expect(container.querySelector(".documents-artifact-gallery--mobile")).toBeInTheDocument();
|
||||
expect(container.querySelector(".artifacts-gallery-grid--mobile")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ArtifactsGallery 2026-07-10-15:40:
|
||||
Any inline-content doc artifact must open a full document viewer with an edit mode that persists through PATCH /artifacts/:id. Binary-backed docs must not offer Edit.
|
||||
*/
|
||||
it("opens the doc viewer, jumps into edit mode, and saves content edits", async () => {
|
||||
mockUseArtifacts.mockReturnValue({
|
||||
artifacts: mockArtifacts,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
const docDetail = mockArtifacts.find((artifact) => artifact.id === "artifact-document")!;
|
||||
mockFetchArtifact.mockResolvedValue(docDetail);
|
||||
mockUpdateArtifact.mockResolvedValue({ ...docDetail, content: "Edited body" });
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show artifacts/i }));
|
||||
|
||||
const docCard = screen.getByRole("article", { name: "Artifact Document artifact" });
|
||||
fireEvent.click(within(docCard).getByRole("button", { name: "Open Document artifact" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Document artifact viewer" });
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByText("Inline document preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /edit document/i }));
|
||||
const editor = within(dialog).getByRole("textbox", { name: "file editor" });
|
||||
fireEvent.change(editor, { target: { value: "Edited body" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /^save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateArtifact).toHaveBeenCalledWith("artifact-document", { content: "Edited body" }, undefined);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Artifact saved", "success");
|
||||
});
|
||||
|
||||
it("clicking project file shows content", async () => {
|
||||
|
||||
@@ -172,6 +172,8 @@ export function useArtifacts(options?: {
|
||||
const unsubscribe = subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"artifact:registered": handleAuthoritativeArtifact,
|
||||
// FNXC:ArtifactRegistry 2026-07-10-15:20: in-place doc edits from the Artifacts viewer emit artifact:updated; open galleries refresh through the same debounced path as registrations.
|
||||
"artifact:updated": handleAuthoritativeArtifact,
|
||||
"message:received": handleArtifactMessage,
|
||||
"message:sent": handleArtifactMessage,
|
||||
},
|
||||
|
||||
@@ -511,4 +511,63 @@ describe("artifacts route integration", () => {
|
||||
rmSync(crossGlobalDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-10-15:20:
|
||||
* The Artifacts view document viewer/editor contract: GET /artifacts/:id returns the full row
|
||||
* INCLUDING inline content (the list route intentionally strips it), and PATCH /artifacts/:id
|
||||
* persists title/description/content edits for inline-content docs, emitting artifact:updated.
|
||||
* Binary-backed artifacts must reject content edits.
|
||||
*/
|
||||
it("GET /artifacts/:id returns inline content and PATCH persists doc edits", async () => {
|
||||
const doc = await store.registerArtifact({
|
||||
type: "document",
|
||||
title: "Design notes",
|
||||
content: "# Original\nbody",
|
||||
mimeType: "text/markdown",
|
||||
authorId: "agent-doc",
|
||||
authorType: "agent",
|
||||
});
|
||||
|
||||
const detail = await REQUEST(app, "GET", `/api/artifacts/${doc.id}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body).toMatchObject({ id: doc.id, content: "# Original\nbody" });
|
||||
|
||||
const listed = await REQUEST(app, "GET", "/api/artifacts");
|
||||
expect((listed.body as ArtifactWithTask[]).find((a) => a.id === doc.id)?.content).toBeFalsy();
|
||||
|
||||
const updated = vi.fn();
|
||||
store.on("artifact:updated", updated);
|
||||
|
||||
const patch = await REQUEST(app, "PATCH", `/api/artifacts/${doc.id}`, JSON.stringify({
|
||||
title: "Design notes v2",
|
||||
content: "# Edited\nbody",
|
||||
}), { "content-type": "application/json" });
|
||||
expect(patch.status).toBe(200);
|
||||
expect(patch.body).toMatchObject({ id: doc.id, title: "Design notes v2", content: "# Edited\nbody" });
|
||||
expect(updated).toHaveBeenCalledTimes(1);
|
||||
|
||||
const reread = await REQUEST(app, "GET", `/api/artifacts/${doc.id}`);
|
||||
expect(reread.body).toMatchObject({ title: "Design notes v2", content: "# Edited\nbody" });
|
||||
});
|
||||
|
||||
it("PATCH /artifacts/:id rejects content edits on binary artifacts, empty updates, and unknown ids", async () => {
|
||||
const { artifact } = await createTaskImageArtifact();
|
||||
|
||||
const binaryPatch = await REQUEST(app, "PATCH", `/api/artifacts/${artifact.id}`, JSON.stringify({ content: "not allowed" }), { "content-type": "application/json" });
|
||||
expect(binaryPatch.status).toBe(400);
|
||||
|
||||
const metadataPatch = await REQUEST(app, "PATCH", `/api/artifacts/${artifact.id}`, JSON.stringify({ description: "New caption" }), { "content-type": "application/json" });
|
||||
expect(metadataPatch.status).toBe(200);
|
||||
expect(metadataPatch.body).toMatchObject({ description: "New caption" });
|
||||
|
||||
const emptyPatch = await REQUEST(app, "PATCH", `/api/artifacts/${artifact.id}`, JSON.stringify({}), { "content-type": "application/json" });
|
||||
expect(emptyPatch.status).toBe(400);
|
||||
|
||||
const missing = await REQUEST(app, "PATCH", "/api/artifacts/does-not-exist", JSON.stringify({ title: "x" }), { "content-type": "application/json" });
|
||||
expect(missing.status).toBe(404);
|
||||
|
||||
const missingGet = await REQUEST(app, "GET", "/api/artifacts/does-not-exist");
|
||||
expect(missingGet.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3857,6 +3857,74 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-10-15:20:
|
||||
The Artifacts view opens documents in a full viewer with edit mode, so it needs a single-artifact
|
||||
read that INCLUDES inline content (listArtifacts intentionally strips content for lightness) and a
|
||||
PATCH that persists title/description/content edits for any inline-content doc. Binary artifacts
|
||||
reject content edits in the store layer.
|
||||
*/
|
||||
router.get("/artifacts/:id", 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");
|
||||
}
|
||||
res.json(artifact);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
throw new ApiError(500, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/artifacts/:id", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const updates: { title?: string; description?: string; content?: string } = {};
|
||||
|
||||
if (body.title !== undefined) {
|
||||
if (typeof body.title !== "string" || body.title.trim().length === 0) {
|
||||
throw badRequest("title must be a non-empty string");
|
||||
}
|
||||
updates.title = body.title;
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
if (typeof body.description !== "string") {
|
||||
throw badRequest("description must be a string");
|
||||
}
|
||||
updates.description = body.description;
|
||||
}
|
||||
if (body.content !== undefined) {
|
||||
if (typeof body.content !== "string") {
|
||||
throw badRequest("content must be a string");
|
||||
}
|
||||
updates.content = body.content;
|
||||
}
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw badRequest("Provide at least one of title, description, or content");
|
||||
}
|
||||
|
||||
const artifact = await scopedStore.updateArtifact(req.params.id, updates);
|
||||
res.json(artifact);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("not found")) {
|
||||
throw notFound("Artifact not found");
|
||||
}
|
||||
if (message.includes("read-only") || message.includes("not editable")) {
|
||||
throw badRequest(message);
|
||||
}
|
||||
throw new ApiError(500, message);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /documents — List all documents across all tasks
|
||||
router.get("/documents", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -573,6 +573,11 @@ export function createSSE(
|
||||
send(`event: artifact:registered\ndata: ${JSON.stringify(artifact)}\n\n`);
|
||||
};
|
||||
|
||||
const onArtifactUpdated = (artifact: unknown) => {
|
||||
/* FNXC:ArtifactRegistry 2026-07-10-15:20: Forward in-place artifact edits (Artifacts view doc editor) so open galleries and viewers refresh without a manual reload. */
|
||||
send(`event: artifact:updated\ndata: ${JSON.stringify(artifact)}\n\n`);
|
||||
};
|
||||
|
||||
const onResearchRunCreated = (run: unknown) => {
|
||||
send(`event: research:run:created\ndata: ${JSON.stringify(run)}\n\n`);
|
||||
};
|
||||
@@ -873,6 +878,7 @@ export function createSSE(
|
||||
store.off("task:merged", onMerged);
|
||||
store.off("agent:log", onAgentLog);
|
||||
store.off("artifact:registered", onArtifactRegistered);
|
||||
store.off("artifact:updated", onArtifactUpdated);
|
||||
if (missionStore) {
|
||||
missionStore.off("mission:created", onMissionCreated);
|
||||
missionStore.off("mission:updated", onMissionUpdated);
|
||||
@@ -988,6 +994,7 @@ export function createSSE(
|
||||
*/
|
||||
store.on("agent:log", onAgentLog);
|
||||
store.on("artifact:registered", onArtifactRegistered);
|
||||
store.on("artifact:updated", onArtifactUpdated);
|
||||
|
||||
if (missionStore) {
|
||||
missionStore.on("mission:created", onMissionCreated);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Artifact, ArtifactType, ArtifactWithTask, MessageStore, TaskStore } from "@fusion/core";
|
||||
@@ -335,6 +335,133 @@ describe("artifact register tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-10-14:30:
|
||||
Agents save screenshots/wireframes/mocks as files in their worktree; `path` registration is the practical ingestion route (inline base64 for real screenshots is impractical). These tests pin: worktree-relative resolution via baseDir, defaultTaskId fallback for executor-lane runs, MIME inference from extension, image signature validation (including SVG text sniff), and payload-source exclusivity.
|
||||
*/
|
||||
describe("artifact register tool path payloads", () => {
|
||||
let baseDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
baseDir = mkdtempSync(join(tmpdir(), "agent-artifact-path-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers an image from a worktree-relative path with inferred mimeType and default task", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-path", type: "image", mimeType: "image/png", content: undefined, uri: "artifacts/after.png" }));
|
||||
mkdirSync(join(baseDir, "screenshots"), { recursive: true });
|
||||
writeFileSync(join(baseDir, "screenshots", "after.png"), PNG_IMAGE_BYTES);
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir, defaultTaskId: TASK_ID });
|
||||
const result = await runTool(tool, "call-path-relative", {
|
||||
type: "image",
|
||||
title: "After screenshot",
|
||||
path: "screenshots/after.png",
|
||||
});
|
||||
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "image",
|
||||
title: "After screenshot",
|
||||
mimeType: "image/png",
|
||||
taskId: TASK_ID,
|
||||
data: PNG_IMAGE_BYTES,
|
||||
}));
|
||||
expect(getText(result)).toContain("Registered artifact");
|
||||
expect(getText(result)).not.toContain("ERROR:");
|
||||
});
|
||||
|
||||
it("prefers an explicit taskId over the defaultTaskId", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-path-explicit", type: "image", taskId: "FN-9999" }));
|
||||
writeFileSync(join(baseDir, "shot.png"), PNG_IMAGE_BYTES);
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir, defaultTaskId: TASK_ID });
|
||||
await runTool(tool, "call-path-explicit-task", {
|
||||
type: "image",
|
||||
title: "Explicit task screenshot",
|
||||
path: "shot.png",
|
||||
taskId: "FN-9999",
|
||||
});
|
||||
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ taskId: "FN-9999" }));
|
||||
});
|
||||
|
||||
it("registers an absolute-path SVG wireframe via text sniffing", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-svg", type: "image", mimeType: "image/svg+xml", content: undefined, uri: "artifacts/wireframe.svg" }));
|
||||
const svgPath = join(baseDir, "wireframe.svg");
|
||||
writeFileSync(svgPath, `<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"/></svg>`);
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
|
||||
const result = await runTool(tool, "call-path-svg", {
|
||||
type: "image",
|
||||
title: "Login wireframe",
|
||||
path: svgPath,
|
||||
});
|
||||
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ mimeType: "image/svg+xml" }));
|
||||
expect(getText(result)).not.toContain("ERROR:");
|
||||
});
|
||||
|
||||
it("rejects a missing file, non-image bytes for image type, and unknown extensions without mimeType", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir });
|
||||
writeFileSync(join(baseDir, "fake.png"), "not a real png");
|
||||
writeFileSync(join(baseDir, "blob.xyz"), "opaque bytes");
|
||||
|
||||
const missingResult = await runTool(tool, "call-path-missing", { type: "image", title: "Missing", path: "nope.png" });
|
||||
const fakeResult = await runTool(tool, "call-path-fake", { type: "image", title: "Fake PNG", path: "fake.png" });
|
||||
const unknownResult = await runTool(tool, "call-path-unknown", { type: "other", title: "Blob", path: "blob.xyz" });
|
||||
|
||||
expect(registerArtifact).not.toHaveBeenCalled();
|
||||
expect(getText(missingResult)).toContain("does not exist or is not readable");
|
||||
expect(getText(fakeResult)).toContain("does not contain valid image bytes");
|
||||
expect(getText(unknownResult)).toContain("Could not infer a MIME type");
|
||||
});
|
||||
|
||||
it("rejects combining path with other payload sources", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
writeFileSync(join(baseDir, "shot.png"), PNG_IMAGE_BYTES);
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir });
|
||||
|
||||
const result = await runTool(tool, "call-path-conflict", {
|
||||
type: "image",
|
||||
title: "Conflicting payloads",
|
||||
path: "shot.png",
|
||||
dataBase64: PNG_IMAGE_BYTES.toString("base64"),
|
||||
});
|
||||
|
||||
expect(registerArtifact).not.toHaveBeenCalled();
|
||||
expect(getText(result)).toContain("path cannot be combined with uri, content, or dataBase64");
|
||||
});
|
||||
|
||||
it("registers non-image media (video) from path with extension-inferred mimeType and no signature gate", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
registerArtifact.mockResolvedValue(createMockArtifact({ id: "art-video", type: "video", mimeType: "video/mp4", content: undefined, uri: "artifacts/demo.mp4" }));
|
||||
writeFileSync(join(baseDir, "demo.mp4"), Buffer.from("fake-video-bytes"));
|
||||
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID, undefined, { baseDir, defaultTaskId: TASK_ID });
|
||||
const result = await runTool(tool, "call-path-video", {
|
||||
type: "video",
|
||||
title: "Feature demo recording",
|
||||
path: "demo.mp4",
|
||||
});
|
||||
|
||||
expect(registerArtifact).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "video",
|
||||
mimeType: "video/mp4",
|
||||
taskId: TASK_ID,
|
||||
data: Buffer.from("fake-video-bytes"),
|
||||
}));
|
||||
expect(getText(result)).not.toContain("ERROR:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifact list tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -544,7 +544,7 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo
|
||||
- fn_list_agents and fn_delegate_task
|
||||
- fn_get_agent_config and fn_update_agent_config (for direct reports only)
|
||||
- fn_agent_create and fn_agent_delete (for direct reports only)
|
||||
- fn_artifact_register, fn_artifact_list, and fn_artifact_view
|
||||
- fn_artifact_register, fn_artifact_list, and fn_artifact_view (register visual outputs — screenshots, wireframes, mockups, diagrams — as type="image" artifacts via the \`path\` parameter so they appear in the dashboard Artifacts gallery)
|
||||
- fn_read_evaluations and fn_update_identity (available in no-task runs)
|
||||
- fn_reflect_on_performance when reflection is enabled for this run
|
||||
- fn_workflow_list, fn_workflow_get, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring
|
||||
@@ -3629,7 +3629,8 @@ export class HeartbeatMonitor {
|
||||
tools.push(createTaskDocumentWriteTool(taskStore, taskId));
|
||||
tools.push(createTaskDocumentReadTool(taskStore, taskId));
|
||||
// Artifact registry tools for cross-agent deliverable discovery and notification.
|
||||
tools.push(createArtifactRegisterTool(taskStore, agentId, messageStore));
|
||||
// FNXC:ArtifactRegistry 2026-07-10-14:30: task-scoped heartbeat registrations default to the assigned task so agent-produced media lands in that task's Artifacts tab.
|
||||
tools.push(createArtifactRegisterTool(taskStore, agentId, messageStore, { defaultTaskId: taskId }));
|
||||
tools.push(createArtifactListTool(taskStore));
|
||||
tools.push(createArtifactViewTool(taskStore));
|
||||
// Agent delegation tools — discover and delegate work to other agents
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core";
|
||||
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon } from "@fusion/core";
|
||||
@@ -140,7 +140,8 @@ export const artifactRegisterParams = Type.Object({
|
||||
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
|
||||
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
|
||||
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
|
||||
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
|
||||
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content, uri, and path when provided." })),
|
||||
path: Type.Optional(Type.String({ description: "Optional local file path to a media file you already saved (screenshot, wireframe, mockup, recording). The file is copied into managed artifact storage. Preferred over dataBase64 for files on disk. Omit content, uri, and dataBase64 when provided." })),
|
||||
taskId: Type.Optional(Type.String({ description: "Optional associated task ID (e.g. 'FN-001')." })),
|
||||
});
|
||||
|
||||
@@ -164,7 +165,8 @@ export const chatArtifactRegisterParams = Type.Object({
|
||||
mimeType: Type.Optional(Type.String({ description: "Optional MIME type, e.g. text/markdown or image/png." })),
|
||||
uri: Type.Optional(Type.String({ description: "Optional URI/path reference when content is stored elsewhere." })),
|
||||
content: Type.Optional(Type.String({ description: "Optional inline text content for document/text artifacts." })),
|
||||
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content and uri when provided." })),
|
||||
dataBase64: Type.Optional(Type.String({ description: "Optional base64-encoded binary payload for image artifacts, e.g. PNG bytes; omit content, uri, and path when provided." })),
|
||||
path: Type.Optional(Type.String({ description: "Optional local file path to a media file you already saved (screenshot, wireframe, mockup, recording). The file is copied into managed artifact storage. Preferred over dataBase64 for files on disk. Omit content, uri, and dataBase64 when provided." })),
|
||||
task_id: Type.String({ description: "Associated task ID (e.g. 'FN-001')." }),
|
||||
});
|
||||
|
||||
@@ -1509,15 +1511,21 @@ export function createChatTaskDocumentTools(store: TaskStore): ToolDefinition[]
|
||||
* FNXC:ArtifactRegistry 2026-06-21-06:50:
|
||||
* Agents need to register multi-type artifacts across agents and tasks while using the existing task store registry. A new artifact registration must also announce itself to the dashboard user's inbox, but that notification is best-effort and must never fail the artifact write.
|
||||
*/
|
||||
export function createArtifactRegisterTool(store: TaskStore, authorId: string, messageStore?: MessageStore): ToolDefinition {
|
||||
export function createArtifactRegisterTool(
|
||||
store: TaskStore,
|
||||
authorId: string,
|
||||
messageStore?: MessageStore,
|
||||
options?: ArtifactRegisterToolOptions,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
name: "fn_artifact_register",
|
||||
label: "Register Artifact",
|
||||
description:
|
||||
"Register an artifact (document, image, video, audio, or other) so other agents and tasks can discover it. " +
|
||||
"Provide inline content, a uri/path reference, or dataBase64 image bytes; optionally associate it with a taskId.",
|
||||
"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. " +
|
||||
"For visual deliverables you saved to disk (screenshots, wireframes, mockups, diagrams, recordings), pass `path` — the file is copied into managed artifact storage. " +
|
||||
"Alternatively provide inline `content` for text documents or `dataBase64` image bytes; optionally associate the artifact with a taskId.",
|
||||
parameters: artifactRegisterParams,
|
||||
execute: async (_id: string, params: Static<typeof artifactRegisterParams>) => registerArtifactForAgent(store, authorId, params, messageStore),
|
||||
execute: async (_id: string, params: Static<typeof artifactRegisterParams>) => registerArtifactForAgent(store, authorId, params, messageStore, options),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1562,7 +1570,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
|
||||
name: "fn_artifact_register",
|
||||
label: "Register Artifact",
|
||||
description:
|
||||
"Register an artifact for a specific task so other agents can discover it. Requires task_id, accepts dataBase64 image bytes, and notifies the dashboard inbox best-effort.",
|
||||
"Register an artifact for a specific task so it appears in the dashboard Artifacts gallery and other agents can discover it. Requires task_id; accepts a local file `path` (screenshots, wireframes, mockups) or dataBase64 image bytes, and notifies the dashboard inbox best-effort.",
|
||||
parameters: chatArtifactRegisterParams,
|
||||
execute: async (_id: string, params: Static<typeof chatArtifactRegisterParams>) => registerArtifactForAgent(
|
||||
store,
|
||||
@@ -1575,6 +1583,7 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
|
||||
uri: params.uri,
|
||||
content: params.content,
|
||||
dataBase64: params.dataBase64,
|
||||
path: params.path,
|
||||
taskId: params.task_id,
|
||||
},
|
||||
messageStore,
|
||||
@@ -1599,25 +1608,39 @@ export function createChatArtifactTools(store: TaskStore, messageStore?: Message
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ArtifactRegistry 2026-07-10-14:30:
|
||||
* Executor-lane artifact registration must default to the executing task so agent-produced media
|
||||
* lands in the per-task Artifacts tab (and gallery task context) even when the agent omits taskId.
|
||||
* `baseDir` anchors relative `path` payloads at the agent's worktree so "screenshots/after.png"
|
||||
* resolves where the agent actually saved it.
|
||||
*/
|
||||
export interface ArtifactRegisterToolOptions {
|
||||
baseDir?: string;
|
||||
defaultTaskId?: string;
|
||||
}
|
||||
|
||||
async function registerArtifactForAgent(
|
||||
store: TaskStore,
|
||||
authorId: string,
|
||||
params: Static<typeof artifactRegisterParams>,
|
||||
messageStore?: MessageStore,
|
||||
options?: ArtifactRegisterToolOptions,
|
||||
) {
|
||||
try {
|
||||
const data = decodeArtifactDataBase64(params);
|
||||
const filePayload = await readArtifactFileFromPath(params, options?.baseDir);
|
||||
const data = filePayload ? filePayload.data : decodeArtifactDataBase64(params);
|
||||
const input: ArtifactCreateInput = {
|
||||
type: params.type,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
mimeType: params.mimeType,
|
||||
mimeType: filePayload?.mimeType ?? params.mimeType,
|
||||
uri: params.uri,
|
||||
content: params.content,
|
||||
data,
|
||||
authorId,
|
||||
authorType: "agent",
|
||||
taskId: params.taskId,
|
||||
taskId: params.taskId ?? options?.defaultTaskId,
|
||||
};
|
||||
|
||||
const artifact: Artifact = await store.registerArtifact(input);
|
||||
@@ -1648,6 +1671,99 @@ async function registerArtifactForAgent(
|
||||
* FNXC:ArtifactRegistry 2026-06-29-17:05:
|
||||
* `dataBase64` is an image-only payload source. Reject empty, non-image, and signature-mismatched bytes early so agents get actionable tool errors instead of persisting artifacts the dashboard cannot preview.
|
||||
*/
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-10-14:30:
|
||||
Agents produce screenshots/wireframes/mockups as files on disk (browser tools, design tooling, ffmpeg), and inlining megabytes of base64 into a tool call is impractical — which is why image artifacts were effectively never created. `path` lets the agent register the file it already saved; the bytes are read here and persisted through TaskStore's managed artifact storage so the registry row keeps a servable managed URI even after the worktree is cleaned up.
|
||||
Image payloads are signature-validated (PNG/JPEG/GIF/WebP binary magic, SVG text sniff) so the dashboard gallery never receives an unpreviewable "image". Non-image media (video/audio/other/document files) only require a resolvable MIME type, inferred from the file extension when omitted.
|
||||
*/
|
||||
const ARTIFACT_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const ARTIFACT_EXTENSION_MIME_TYPES: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".pdf": "application/pdf",
|
||||
".html": "text/html",
|
||||
".md": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
".json": "application/json",
|
||||
};
|
||||
|
||||
async function readArtifactFileFromPath(
|
||||
params: Static<typeof artifactRegisterParams>,
|
||||
baseDir?: string,
|
||||
): Promise<{ data: Buffer; mimeType: string } | undefined> {
|
||||
if (params.path === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawPath = params.path.trim();
|
||||
if (rawPath.length === 0) {
|
||||
throw new Error("path must reference a file on disk.");
|
||||
}
|
||||
|
||||
if (params.uri || params.content || params.dataBase64) {
|
||||
throw new Error("path cannot be combined with uri, content, or dataBase64; provide exactly one artifact payload source.");
|
||||
}
|
||||
|
||||
const resolvedPath = isAbsolute(rawPath) ? rawPath : resolve(baseDir ?? process.cwd(), rawPath);
|
||||
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await stat(resolvedPath);
|
||||
} catch {
|
||||
throw new Error(`path ${resolvedPath} does not exist or is not readable.`);
|
||||
}
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error(`path ${resolvedPath} is not a regular file.`);
|
||||
}
|
||||
|
||||
if (fileStat.size === 0) {
|
||||
throw new Error(`path ${resolvedPath} is empty.`);
|
||||
}
|
||||
|
||||
if (fileStat.size > ARTIFACT_FILE_MAX_BYTES) {
|
||||
throw new Error(`path ${resolvedPath} is ${fileStat.size} bytes, above the ${ARTIFACT_FILE_MAX_BYTES}-byte artifact limit.`);
|
||||
}
|
||||
|
||||
const inferredMime = ARTIFACT_EXTENSION_MIME_TYPES[extname(resolvedPath).toLowerCase()];
|
||||
const mimeType = params.mimeType?.toLowerCase().split(";", 1)[0] ?? inferredMime;
|
||||
if (!mimeType) {
|
||||
throw new Error(`Could not infer a MIME type from ${resolvedPath}; pass mimeType explicitly.`);
|
||||
}
|
||||
|
||||
const data = await readFile(resolvedPath);
|
||||
|
||||
if (params.type === "image") {
|
||||
if (!mimeType.startsWith("image/")) {
|
||||
throw new Error(`image artifacts require an image/* mimeType, got ${mimeType}.`);
|
||||
}
|
||||
if (!isValidImagePayload(data, mimeType)) {
|
||||
throw new Error(`path ${resolvedPath} does not contain valid image bytes matching mimeType ${mimeType}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return { data, mimeType };
|
||||
}
|
||||
|
||||
function isValidImagePayload(data: Buffer, mimeType: string): boolean {
|
||||
if (mimeType === "image/svg+xml") {
|
||||
const head = data.subarray(0, 4096).toString("utf8").trimStart();
|
||||
return head.startsWith("<svg") || (head.startsWith("<?xml") && head.includes("<svg"));
|
||||
}
|
||||
return hasImageSignature(data, mimeType);
|
||||
}
|
||||
|
||||
function decodeArtifactDataBase64(params: Static<typeof artifactRegisterParams>): Buffer | undefined {
|
||||
if (params.dataBase64 === undefined) {
|
||||
return undefined;
|
||||
|
||||
@@ -1452,6 +1452,15 @@ Documents are versioned — each write creates a new revision. Use meaningful ke
|
||||
|
||||
Use \`fn_artifact_register\` to register multi-type artifacts for discovery across agents and tasks, \`fn_artifact_list\` to find registered artifacts by type/author/task/search, and \`fn_artifact_view\` to inspect artifact metadata plus inline content or URI references. Artifact registration sends a best-effort system inbox notification to the dashboard user; notification failures do not make registration fail.
|
||||
|
||||
**IMPORTANT — Register visual deliverables as image artifacts:** Whenever you produce a visual output — a screenshot of the app or a UI change, a wireframe, a design mockup, a diagram, a rendered chart, a before/after capture — you MUST register it so it appears in the dashboard Artifacts gallery:
|
||||
|
||||
1. Save the file to disk in your worktree (e.g. \`screenshots/after.png\`).
|
||||
2. Call \`fn_artifact_register(type="image", title="Settings modal — after fix", description="What this shows and why it matters", path="screenshots/after.png")\`.
|
||||
|
||||
Relative paths resolve against your worktree, and the file is COPIED into managed storage — so register even files you do not commit, and register before the worktree is cleaned up. Supported image formats: PNG, JPEG, GIF, WebP, SVG. For video/audio recordings use \`type="video"\`/\`type="audio"\` with \`path\`; for HTML mockups or text deliverables use \`type="document"\` with inline \`content\`. Artifacts you register are associated with this task automatically.
|
||||
|
||||
Register visual evidence proactively for any UI-affecting task: capture at least one screenshot demonstrating the final result when the change has a visible surface. If the task asks for wireframes, mockups, or designs, the registered image artifacts ARE the deliverable.
|
||||
|
||||
**IMPORTANT — Save your deliverables as documents:** When your task produces written output (documentation, specifications, reports, API references, README updates, guides, or any other content), you MUST save that content as a task document using \`fn_task_document_write\`. Use a key that describes the deliverable (e.g., key="readme", key="api-docs", key="changelog"). Do this in addition to writing the file to disk — the document persists in the task for review even after the worktree is cleaned up.
|
||||
|
||||
If the task's PROMPT.md includes a "Documentation Requirements" section listing files to update, save each updated file's final content as a task document with a matching key.
|
||||
@@ -10509,12 +10518,16 @@ export class TaskExecutor {
|
||||
this.createTaskDocumentReadTool(task.id),
|
||||
// FNXC:FileScope 2026-07-08-22:40: let the coding agent extend its own declared ## File Scope at runtime (fn_task_file_scope_add) so edits beyond the initial scope are not stranded by the scope-aware squash merge.
|
||||
this.createTaskFileScopeAddTool(task.id),
|
||||
// FNXC:ArtifactRegistry 2026-06-21-07:04: Artifact list/view are read-only discovery tools and must remain available even when the task has no assigned agent identity; only registration requires an authorId for persisted attribution and best-effort inbox notification.
|
||||
this.createArtifactListTool(),
|
||||
this.createArtifactViewTool(),
|
||||
...(assignedAgentId ? [
|
||||
this.createArtifactRegisterTool(assignedAgentId),
|
||||
] : []),
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-10-14:30:
|
||||
fn_artifact_register was previously gated on assignedAgentId, but default ephemeral mode never
|
||||
sets assignedAgentId on in-progress tasks — so executor agents never had the register tool at
|
||||
all and agent-produced screenshots/wireframes could not reach the Artifacts gallery. Always
|
||||
expose it, attributing ephemeral runs to the established "executor" fallback author.
|
||||
*/
|
||||
this.createArtifactRegisterTool(assignedAgentId ?? "executor", task.id, worktreePath),
|
||||
this.createWorkflowListTool(),
|
||||
this.createWorkflowGetTool(),
|
||||
this.createWorkflowSelectTool(task.id),
|
||||
@@ -12534,8 +12547,17 @@ export class TaskExecutor {
|
||||
return sharedCreateTaskFileScopeAddTool(this.store, taskId, this.getRunContextFor(taskId));
|
||||
}
|
||||
|
||||
private createArtifactRegisterTool(authorId: string): ToolDefinition {
|
||||
return sharedCreateArtifactRegisterTool(this.store, authorId, this.options.messageStore);
|
||||
/*
|
||||
FNXC:ArtifactRegistry 2026-07-10-14:30:
|
||||
Executor-lane registration anchors relative `path` payloads at the task worktree (where the agent
|
||||
saves screenshots/wireframes/mocks) and defaults taskId to the executing task so agent-produced
|
||||
media surfaces in the per-task Artifacts tab without the agent having to repeat its own task id.
|
||||
*/
|
||||
private createArtifactRegisterTool(authorId: string, taskId: string, worktreePath: string): ToolDefinition {
|
||||
return sharedCreateArtifactRegisterTool(this.store, authorId, this.options.messageStore, {
|
||||
baseDir: worktreePath,
|
||||
defaultTaskId: taskId,
|
||||
});
|
||||
}
|
||||
|
||||
private createArtifactListTool(): ToolDefinition {
|
||||
|
||||
Reference in New Issue
Block a user