Add company artifacts page (#7621)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators need a way to inspect files and work products created by
agents across a company without opening each issue one by one.
> - The existing issue detail surfaces already show attachments and
outputs, but there was no company-level artifacts index or search-result
affordance for artifact-like records.
> - The backend needed a company-scoped artifacts projection API that
preserves issue/run attribution and safe links back to source records.
> - The UI needed a first-class Artifacts page, sidebar entry, reusable
artifact cards, and deep-link handling that keeps company prefixes
intact.
> - This pull request adds the company artifacts API and page, then
wires artifacts into search and issue output surfaces.
> - The benefit is a single place to browse, filter, and open generated
work products and attachments while preserving company boundaries.

## Linked Issues or Issue Description

Fixes #7622.

Feature request fields:

- Problem/motivation: company operators need a consolidated artifacts
surface for attachments and work products produced by agents.
- Proposed solution: add a company-scoped artifacts projection endpoint,
a board Artifacts route, reusable cards, sidebar navigation, and
artifact search integration.
- Alternatives considered: keep artifact discovery only on individual
issue pages; that forces operators to know the source issue before
finding generated outputs.
- Roadmap alignment: checked `ROADMAP.md`; this is a focused board
UI/API improvement and does not duplicate a listed roadmap item.

## What Changed

- Added shared artifact types and validators.
- Added a company-scoped artifact projection service/API with tests for
attachment/work-product attribution.
- Added Artifacts board UI route, API client, sidebar link, cards,
filters, and storybook coverage.
- Added artifact result handling to company search and issue
output/deep-link flows.
- Rebased the branch onto the latest `public-gh/master` state and
resolved the route-test conflict by preserving both upstream
team-catalog coverage and artifact route coverage.
- Fixed a local Sidebar test helper so it no longer depends on a
runtime-undefined `React.act` export in this dependency install.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/artifacts/ArtifactCard.test.tsx src/api/artifacts.test.ts
src/lib/company-routes.test.ts`
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Artifacts.test.tsx src/pages/Search.test.tsx
src/components/Sidebar.test.tsx`
- `pnpm exec vitest run
server/src/__tests__/company-artifacts-service.test.ts
server/src/__tests__/company-search-service.test.ts
server/src/__tests__/company-search-rate-limit-routes.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows/*`.
- Duplicate search: no open PRs or issues found for `artifact page
ArtifactCard` in `paperclipai/paperclip`.

Screenshots are intentionally omitted per the internal task instruction
not to add design screenshots or images to this PR unless they are
specifically part of the work. I also attempted browser capture in this
runner, but `agent-browser` failed to launch Chrome and Playwright
Chromium is missing `libatk-1.0.so.0`.

## Risks

- Low-to-medium risk: this adds a new API projection and UI surface, so
attribution/link regressions could affect artifact navigation.
- Company scoping is covered in the new service/API tests.
- No database migrations are included.
- No lockfile or workflow changes are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent with tool use and local command
execution. Exact hosted model identifier is not exposed in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (intentionally omitted per task instruction; browser capture
unavailable in this runner)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-06-05 13:11:05 -10:00
committed by GitHub
parent dbebf30c89
commit 4693d770aa
39 changed files with 2706 additions and 71 deletions
@@ -103,7 +103,7 @@ function MarkdownAttachmentCard({
});
return (
<div className="rounded-lg border border-border p-3">
<div id={`attachment-${attachment.id}`} className="scroll-mt-20 rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
@@ -142,7 +142,7 @@ function VideoAttachmentCard({
}) {
const filename = attachmentFilename(attachment);
return (
<div className="overflow-hidden rounded-md border border-border bg-card">
<div id={`attachment-${attachment.id}`} className="scroll-mt-20 overflow-hidden rounded-md border border-border bg-card">
<OutputVideoPlayer src={attachment.contentPath} title={filename} />
<div className="flex flex-col gap-2 p-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
@@ -166,7 +166,7 @@ function GenericAttachmentRow({
}) {
const filename = attachmentFilename(attachment);
return (
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card p-2">
<div id={`attachment-${attachment.id}`} className="flex scroll-mt-20 items-center gap-2.5 rounded-md border border-border bg-card p-2">
<OutputFileTile contentType={attachment.contentType} />
<div className="min-w-0 flex-1">
<a
@@ -257,7 +257,8 @@ export function IssueAttachmentsSection({
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border border-border bg-accent/10"
id={`attachment-${attachment.id}`}
className="group relative aspect-square cursor-pointer scroll-mt-20 overflow-hidden rounded-lg border border-border bg-accent/10"
onClick={() => onImageClick(attachment)}
>
<img
+29 -12
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -90,14 +90,12 @@ vi.mock("./SidebarAgents", () => ({
SidebarAgents: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
flushSync(() => {});
}
describe("Sidebar", () => {
@@ -109,7 +107,7 @@ describe("Sidebar", () => {
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<Sidebar />
@@ -142,7 +140,7 @@ describe("Sidebar", () => {
const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim());
expect(workLinks).not.toContain("Search");
await act(async () => {
flushSync(() => {
root.unmount();
});
});
@@ -159,7 +157,7 @@ describe("Sidebar", () => {
expect(workSectionContainer?.textContent).toContain("Issues");
expect(workSectionContainer?.textContent).toContain("Goals");
await act(async () => {
flushSync(() => {
root.unmount();
});
});
@@ -181,7 +179,7 @@ describe("Sidebar", () => {
expect(primaryNavText).toContain("Inbox");
expect(primaryNavText).not.toContain("Plugin slot outlet");
await act(async () => {
flushSync(() => {
root.unmount();
});
});
@@ -192,7 +190,26 @@ describe("Sidebar", () => {
expect(container.textContent).not.toContain("Workspaces");
await act(async () => {
flushSync(() => {
root.unmount();
});
});
it("shows an Artifacts nav item directly below Goals", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const artifactsLink = [...container.querySelectorAll("a")].find(
(anchor) => anchor.textContent === "Artifacts",
);
expect(artifactsLink?.getAttribute("href")).toBe("/artifacts");
const navText = container.querySelector("nav")?.textContent ?? "";
expect(navText).toContain("Goals");
expect(navText).toContain("Artifacts");
expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts"));
flushSync(() => {
root.unmount();
});
});
@@ -204,7 +221,7 @@ describe("Sidebar", () => {
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces");
expect(link?.getAttribute("href")).toBe("/workspaces");
await act(async () => {
flushSync(() => {
root.unmount();
});
});
+2
View File
@@ -11,6 +11,7 @@ import {
Boxes,
Repeat,
GitBranch,
Package,
Settings,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
@@ -97,6 +98,7 @@ export function Sidebar() {
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
@@ -0,0 +1,165 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/router", () => ({
Link: ({
to,
children,
disableIssueQuicklook,
...props
}: {
to: string;
children: ReactNode;
disableIssueQuicklook?: boolean;
}) => (
<a href={to} data-disable-issue-quicklook={disableIssueQuicklook ? "true" : undefined} {...props}>
{children}
</a>
),
}));
import { ArtifactCard } from "./ArtifactCard";
import type { CompanyArtifact } from "@/api/artifacts";
function makeArtifact(overrides: Partial<CompanyArtifact> = {}): CompanyArtifact {
return {
id: "art-1",
source: "attachment",
mediaKind: "image",
title: "Hero shot",
previewText: null,
contentType: "image/png",
contentPath: "/files/art-1.png",
openPath: "/files/art-1.png",
downloadPath: "/files/art-1.png?download=1",
issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals" },
project: { id: "proj-1", name: "Paperclip App" },
createdByAgent: { id: "agent-1", name: "ClaudeCoder" },
updatedAt: "2026-06-01T00:00:00.000Z",
href: "/issues/PAP-10306#attachment-art-1",
...overrides,
};
}
describe("ArtifactCard", () => {
it("renders an image preview with cover image and links to the issue anchor", () => {
const markup = renderToStaticMarkup(<ArtifactCard artifact={makeArtifact()} />);
expect(markup).toContain('href="/issues/PAP-10306#attachment-art-1"');
expect(markup).toContain('data-disable-issue-quicklook="true"');
expect(markup).toContain('data-media-kind="image"');
expect(markup).toContain("rounded-[8px]");
expect(markup).toContain('src="/files/art-1.png"');
expect(markup).toContain("object-cover");
expect(markup).toContain("Hero shot");
expect(markup).toContain("flex h-7 items-start justify-between gap-2");
expect(markup).toContain("leading-7");
expect(markup).toContain("Last edited Jun 1, 2026");
expect(markup).not.toContain("Landing visuals");
expect(markup).not.toContain("Edited ");
});
it("renders only the artifact subject and absolute metadata under the preview", () => {
const markup = renderToStaticMarkup(
<ArtifactCard
artifact={makeArtifact({
title: "Social launch clip",
issue: { id: "issue-2", identifier: "PAP-10370", title: "Make artifact page look like this" },
updatedAt: "2025-10-08T12:00:00.000Z",
createdByAgent: null,
})}
/>,
);
expect(markup).toContain("Social launch clip");
expect(markup).toContain("Last edited Oct 8, 2025");
expect(markup).not.toContain("Make artifact page look like this");
expect(markup).not.toContain(">PAP-10370<");
});
it("renders a video preview with a video element and play glyph", () => {
const markup = renderToStaticMarkup(
<ArtifactCard
artifact={makeArtifact({ mediaKind: "video", contentType: "video/mp4", contentPath: "/files/clip.mp4" })}
/>,
);
expect(markup).toContain('data-media-kind="video"');
expect(markup).toContain("<video");
expect(markup).toContain('preload="metadata"');
});
it("renders a falling-back video placeholder when no content path exists", () => {
const markup = renderToStaticMarkup(
<ArtifactCard artifact={makeArtifact({ mediaKind: "video", contentPath: null })} />,
);
expect(markup).toContain('data-media-kind="video"');
expect(markup).not.toContain("<video");
});
it("renders a document preview excerpt", () => {
const markup = renderToStaticMarkup(
<ArtifactCard
artifact={makeArtifact({
source: "document",
mediaKind: "document",
contentType: "text/markdown",
contentPath: null,
previewText: "This is the plan preview excerpt.",
})}
/>,
);
expect(markup).toContain('data-media-kind="document"');
expect(markup).toContain("This is the plan preview excerpt.");
expect(markup).toContain("text-base");
expect(markup).toContain("leading-6");
expect(markup).toContain("max-h-full");
expect(markup).toContain("overflow-hidden");
expect(markup).not.toContain('data-lucide="file-text"');
});
it("renders a placeholder for empty artifacts without an image or video", () => {
const markup = renderToStaticMarkup(
<ArtifactCard
artifact={makeArtifact({ mediaKind: "empty", contentType: null, contentPath: null, previewText: null })}
/>,
);
expect(markup).toContain('data-media-kind="empty"');
expect(markup).not.toContain("<img");
expect(markup).not.toContain("<video");
});
it("omits open/download actions when no file paths exist (e.g. documents)", () => {
const markup = renderToStaticMarkup(
<ArtifactCard
artifact={makeArtifact({
source: "document",
mediaKind: "document",
contentPath: null,
openPath: null,
downloadPath: null,
previewText: "Plan body",
})}
/>,
);
expect(markup).not.toContain('aria-label="Download file"');
expect(markup).not.toContain('aria-label="Open file in new tab"');
});
it("reserves the same title row height when file actions are absent", () => {
const markup = renderToStaticMarkup(
<ArtifactCard
artifact={makeArtifact({
openPath: null,
downloadPath: null,
createdByAgent: null,
})}
/>,
);
expect(markup).toContain("flex h-7 items-start justify-between gap-2");
expect(markup).toContain("leading-7");
expect(markup).toContain("Last edited Jun 1, 2026");
expect(markup).not.toContain('aria-label="Download file"');
expect(markup).not.toContain('aria-label="Open file in new tab"');
});
});
@@ -0,0 +1,187 @@
import { useState } from "react";
import { Download, ExternalLink, Paperclip, Play } from "lucide-react";
import type { CompanyArtifact } from "@/api/artifacts";
import { Link } from "@/lib/router";
import { cn, formatDate } from "@/lib/utils";
interface ArtifactCardProps {
artifact: CompanyArtifact;
}
/**
* Stable, fixed-height preview region shared by every card variant. The fixed
* aspect ratio is what keeps image / video / text / placeholder cards from
* shifting layout as previews load (or fail to load).
*/
function PreviewFrame({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("relative aspect-video w-full overflow-hidden bg-accent/20", className)}>
{children}
</div>
);
}
function PlaceholderPreview({ label }: { label?: string }) {
return (
<PreviewFrame className="flex items-center justify-center">
<div className="flex flex-col items-center gap-1.5 text-muted-foreground/50">
<Paperclip className="h-7 w-7" aria-hidden="true" />
{label ? <span className="text-[11px] font-medium uppercase tracking-wide">{label}</span> : null}
</div>
</PreviewFrame>
);
}
function ImagePreview({ artifact }: { artifact: CompanyArtifact }) {
const [errored, setErrored] = useState(false);
if (errored || !artifact.contentPath) {
return <PlaceholderPreview label="Image" />;
}
return (
<PreviewFrame>
<img
src={artifact.contentPath}
alt={artifact.title}
loading="lazy"
className="h-full w-full object-cover"
onError={() => setErrored(true)}
/>
</PreviewFrame>
);
}
function VideoPreview({ artifact }: { artifact: CompanyArtifact }) {
const [errored, setErrored] = useState(false);
if (errored || !artifact.contentPath) {
return (
<PreviewFrame className="flex items-center justify-center bg-black/80">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/15">
<Play className="h-5 w-5 translate-x-0.5 text-white" aria-hidden="true" />
</div>
</PreviewFrame>
);
}
return (
<PreviewFrame className="bg-black">
<video
src={artifact.contentPath}
preload="metadata"
muted
playsInline
className="h-full w-full object-contain"
onError={() => setErrored(true)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-black/55">
<Play className="h-5 w-5 translate-x-0.5 text-white" aria-hidden="true" />
</div>
</div>
</PreviewFrame>
);
}
function TextPreview({ artifact }: { artifact: CompanyArtifact }) {
const preview = artifact.previewText?.trim();
if (!preview) {
return <PlaceholderPreview label={artifact.source === "document" ? "Document" : "Text"} />;
}
return (
<PreviewFrame className="bg-card">
<div className="absolute inset-0 overflow-hidden p-3">
<p className="max-h-full overflow-hidden whitespace-pre-wrap break-words text-base leading-6 text-muted-foreground/75">
{preview}
</p>
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-card to-transparent" />
</PreviewFrame>
);
}
function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) {
switch (artifact.mediaKind) {
case "image":
return <ImagePreview artifact={artifact} />;
case "video":
return <VideoPreview artifact={artifact} />;
case "text":
case "document":
return <TextPreview artifact={artifact} />;
case "file":
return <PlaceholderPreview label="File" />;
case "empty":
default:
return <PlaceholderPreview />;
}
}
function SecondaryAction({
href,
download,
title,
children,
}: {
href: string;
download?: boolean;
title: string;
children: React.ReactNode;
}) {
return (
<a
href={href}
{...(download ? { download: "" } : { target: "_blank", rel: "noreferrer" })}
title={title}
aria-label={title}
onClick={(event) => event.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{children}
</a>
);
}
export function ArtifactCard({ artifact }: ArtifactCardProps) {
return (
<Link
to={artifact.href}
disableIssueQuicklook
data-testid="artifact-card"
data-media-kind={artifact.mediaKind}
className="group flex flex-col overflow-hidden rounded-[8px] border border-border bg-card transition-colors hover:border-foreground/20"
>
<ArtifactPreview artifact={artifact} />
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex h-7 items-start justify-between gap-2">
<h3
className="min-w-0 flex-1 truncate text-sm font-medium leading-7 text-foreground/85"
title={artifact.title}
>
{artifact.title}
</h3>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{artifact.openPath ? (
<SecondaryAction href={artifact.openPath} title="Open file in new tab">
<ExternalLink className="h-3.5 w-3.5" />
</SecondaryAction>
) : null}
{artifact.downloadPath ? (
<SecondaryAction href={artifact.downloadPath} download title="Download file">
<Download className="h-3.5 w-3.5" />
</SecondaryAction>
) : null}
</div>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground/65">
<span>Last edited {formatDate(artifact.updatedAt)}</span>
{artifact.createdByAgent ? (
<>
<span className="text-muted-foreground/50">·</span>
<span className="truncate">{artifact.createdByAgent.name}</span>
</>
) : null}
</div>
</div>
</Link>
);
}
@@ -34,13 +34,19 @@ export function IssueOutputSection({ workProducts, resolveCreatorName }: IssueOu
<span className="text-xs text-muted-foreground">{count}</span>
</div>
<OutputPrimaryCard item={primary} creatorName={creatorFor(primary)} />
{/* Stable anchor target so company Artifacts cards can deep-link to a
specific work product inside its issue context (PAP-10359). */}
<div id={`work-product-${primary.id}`} className="scroll-mt-20">
<OutputPrimaryCard item={primary} creatorName={creatorFor(primary)} />
</div>
{rest.length > 0 && (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Also produced</p>
{rest.map((item) => (
<OutputRow key={item.id} item={item} creatorName={creatorFor(item)} />
<div key={item.id} id={`work-product-${item.id}`} className="scroll-mt-20">
<OutputRow item={item} creatorName={creatorFor(item)} />
</div>
))}
</div>
)}
+51 -1
View File
@@ -1,5 +1,5 @@
import { memo, type ComponentType, type SVGProps } from "react";
import { Bot, FileText, Hexagon, MessageSquare, Quote } from "lucide-react";
import { Bot, FileText, Hexagon, MessageSquare, Paperclip, Quote } from "lucide-react";
import type { Agent, CompanySearchResult } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
@@ -15,6 +15,7 @@ type SnippetStyle = {
const SNIPPET_STYLES: Record<string, SnippetStyle> = {
comment: { Icon: MessageSquare, label: "Comment" },
document: { Icon: FileText, label: "Doc" },
artifact: { Icon: Paperclip, label: "Artifact" },
description: { Icon: Quote, label: "Description" },
};
@@ -109,6 +110,55 @@ function SearchResultRowImpl({
);
}
if (result.type === "artifact") {
const artifact = result.artifact;
if (!artifact) return null;
const updated = formatRelativeTime(result.updatedAt ?? artifact.updatedAt);
return (
<Link
to={result.href}
disableIssueQuicklook
className={cn(ROW_BASE, "py-4", isActive && "bg-muted/40", className)}
data-result-type="artifact"
>
<Paperclip className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1">
<span className="truncate text-sm font-medium text-foreground">{result.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{artifact.issueIdentifier}
</span>
</div>
{result.snippet ? (
<SnippetLine
text={result.snippets[0]?.text ?? result.snippet}
highlights={result.snippets[0]?.highlights}
field="artifact"
fallbackLabel={result.sourceLabel ?? "Artifact"}
multiline
/>
) : null}
<div className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground sm:hidden">
<span className="truncate">{artifact.issueTitle}</span>
{updated ? <span className="ml-auto shrink-0 tabular-nums">{updated}</span> : null}
</div>
</div>
<div className="ml-2 hidden shrink-0 flex-col items-end gap-2 sm:flex">
{updated ? <span className="text-xs tabular-nums text-muted-foreground">{updated}</span> : null}
{result.previewImageUrl ? (
<img
src={result.previewImageUrl}
alt=""
loading="lazy"
decoding="async"
className="h-[88px] w-[88px] shrink-0 rounded-md border border-border bg-muted object-cover"
/>
) : null}
</div>
</Link>
);
}
const issue = result.issue;
if (!issue) return null;
const assigneeName = issue.assigneeAgentId