diff --git a/doc/design/CHANGING-THE-UI.md b/doc/design/CHANGING-THE-UI.md index 49ee2a721e..77602f0ae1 100644 --- a/doc/design/CHANGING-THE-UI.md +++ b/doc/design/CHANGING-THE-UI.md @@ -72,3 +72,9 @@ This system was built to be steered by instruction. "Make all running indicators - **ESLint ratchet** — will eventually enforce the token rules at lint time; until then `check:token-gates` is the gate. See `DECISION-SHEET.md` for the full ledger. + +### Task artifact galleries + +The task Artifacts tab renders images and videos with `MediaArtifactCard`, including agent attachments that have not been promoted to work products. Its container-responsive grid preserves run grouping and chronological order; documents and other outputs span the full width. `RichWorkProductCard` supports `gallery` for media and keeps `card`/`compact` rows for other outputs. Rows use a stretched native link or button so titles, thumbnails, metadata, and padding activate the same action, with keyboard focus and normal link modifiers preserved. + +`ArtifactPreview` is shared with the company gallery. Video previews are muted, never autoplay, and seek up to one second into the clip after metadata loads. Changing the source resets preview state. The existing task gallery handles playback and download. Storybook **Tasks → Artifact Gallery** covers eight generated video outputs, mixed images/files, full-row activation, narrow/expanded panels, empty state, unavailable previews, and light mode using small offline fixtures. diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index 9046b30ece..f8dc36a591 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -132,6 +132,34 @@ describe("ArtifactCard", () => { container.remove(); }); + it("seeks into clips and resets thumbnail state when the source changes", () => { + const container = document.createElement("div"); + const root = createRoot(container); + const render = (contentPath: string) => flushSync(() => root.render( + , + )); + try { + render("/first.mp4"); + const first = container.querySelector("video")!; + Object.defineProperty(first, "duration", { value: 8 }); + flushSync(() => first.dispatchEvent(new Event("loadedmetadata"))); + expect(first.currentTime).toBe(1); + flushSync(() => first.dispatchEvent(new Event("seeked"))); + expect(first.dataset.frameReady).toBe("true"); + render("/short.mp4"); + const next = container.querySelector("video")!; + expect(next).not.toBe(first); + expect(next.dataset.frameReady).toBe("false"); + Object.defineProperty(next, "duration", { value: 0.2 }); + flushSync(() => next.dispatchEvent(new Event("loadedmetadata"))); + expect(next.currentTime).toBe(0.05); + expect(next.autoplay).toBe(false); + expect(next.muted).toBe(true); + } finally { + flushSync(() => root.unmount()); + } + }); + it("reveals video previews if the browser does not report seek completion", () => { vi.useFakeTimers(); const container = document.createElement("div"); diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index 2415d4e729..0fbb5349d9 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -32,7 +32,9 @@ function PlaceholderPreview({ label }: { label?: string }) { ); } -function ImagePreview({ artifact }: { artifact: CompanyArtifact }) { +type PreviewArtifact = Pick & Partial>; + +function ImagePreview({ artifact }: { artifact: PreviewArtifact }) { const [errored, setErrored] = useState(false); if (errored || !artifact.contentPath) { return ; @@ -50,7 +52,7 @@ function ImagePreview({ artifact }: { artifact: CompanyArtifact }) { ); } -function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { +function VideoPreview({ artifact }: { artifact: PreviewArtifact }) { const [errored, setErrored] = useState(false); const [frameReady, setFrameReady] = useState(false); const thumbnailSeekRequested = useRef(false); @@ -90,7 +92,7 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { thumbnailSeekRequested.current = true; const video = event.currentTarget; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; - const seekTarget = duration > 0 ? Math.min(0.12, duration / 2) : 0.05; + const seekTarget = duration > 0 ? Math.min(1, duration / 4) : 0.05; try { if (Math.abs(video.currentTime - seekTarget) > 0.001) { video.currentTime = seekTarget; @@ -131,7 +133,7 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { ); } -function TextPreview({ artifact }: { artifact: CompanyArtifact }) { +function TextPreview({ artifact }: { artifact: PreviewArtifact }) { const preview = artifact.previewText?.trim(); if (!preview) { return ; @@ -148,12 +150,12 @@ function TextPreview({ artifact }: { artifact: CompanyArtifact }) { ); } -export function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) { +export function ArtifactPreview({ artifact }: { artifact: PreviewArtifact }) { switch (artifact.mediaKind) { case "image": - return ; + return ; case "video": - return ; + return ; case "text": case "document": return ; diff --git a/ui/src/components/artifacts/MediaArtifactCard.tsx b/ui/src/components/artifacts/MediaArtifactCard.tsx new file mode 100644 index 0000000000..9407ab9294 --- /dev/null +++ b/ui/src/components/artifacts/MediaArtifactCard.tsx @@ -0,0 +1,41 @@ +import { useContext, useState, type ReactNode } from "react"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; +import { ImageGalleryModal } from "@/components/ImageGalleryModal"; +import { isVideoLikeOutput } from "@/lib/issue-output"; +import { ArtifactPreview } from "./ArtifactCard"; + +/** A media tile shared by uploaded files and attachment-backed work products. */ +export function MediaArtifactCard({ id, title, contentPath, contentType, originalFilename, downloadPath, detail, badge }: { + id: string; + title: string; + contentPath: string; + contentType: string; + originalFilename: string; + downloadPath?: string; + detail?: string; + badge?: ReactNode; +}) { + const openIssueGallery = useContext(IssueGalleryContext); + const [open, setOpen] = useState(false); + const mediaKind = isVideoLikeOutput(contentType, originalFilename) ? "video" : "image"; + return ( + <> + + {open ? : null} + + ); +} diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx index 64e0613ee3..2608e89610 100644 --- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx @@ -32,7 +32,9 @@ import { selectAgentArtifactAttachments, workProductHref, } from "@/lib/issue-artifacts"; -import { attachmentOpenPath } from "@/lib/issue-attachments"; +import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard"; +import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output"; +import { attachmentDownloadPath, attachmentOpenPath } from "@/lib/issue-attachments"; import { MarkdownBody } from "@/components/MarkdownBody"; import { RichWorkProductCard } from "@/components/task-chat/RichWorkProductCard"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations"; @@ -75,7 +77,7 @@ function workProductStatusBadge(status: string): { label: string; cssVar: string } const ROW_CLASS = - "flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm"; + "flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; /** * Work-product row for an eligible Markdown artifact (LOOA-1533 gap): expands @@ -438,7 +440,7 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc } return ( -
+
{groupedRows.map((group) => { const run = group.runId === "other" ? null : runsById.get(group.runId); const agent = run ? agentsById.get(run.agentId) : null; @@ -454,7 +456,7 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc ) : null} -