mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Issue detail pages promote certain artifact work products into the dedicated Output surface while also listing raw attachments below > - Document-like artifacts such as plan markdown can currently be promoted like binary outputs, which makes the same work product story look like both an output and a document/attachment > - The output surface should stay focused on inspectable generated media, archives, PDFs, WebAssembly, SVG/images, and true binary deliverables while document-like artifacts remain in the document or attachment flow > - This pull request filters document-like artifact metadata out of the Output section and avoids duplicating the attachments that back promoted outputs > - The benefit is a cleaner issue detail page where plans and markdown reports do not appear as binary outputs, while real output files still get highlighted ## Linked Issues or Issue Description Fixes #7609 Refs PAP-10354 Refs PAP-10369 ## What Changed - Added output MIME-type normalization and eligibility checks for issue artifact work products. - Filtered markdown, text, JSON, XML, CSV, YAML, source-like files, and generic binary artifacts with document-like filenames out of promoted issue outputs. - Kept video, image including SVG, PDF, ZIP, WebAssembly, and true binary artifacts eligible for the Output section. - Hid attachments that back promoted outputs while leaving filtered document-like artifact attachments visible. - Preserved the full image attachment set for chat image gallery lookup even when promoted image outputs are hidden from the attachment list. - Added focused tests for output eligibility, glyph labeling, output promotion, attachment filtering, gallery image preservation, and the output section render behavior. ## Verification - `pnpm vitest run ui/src/lib/issue-output.test.ts ui/src/pages/IssueDetail.test.tsx ui/src/components/issue-output/IssueOutputSection.test.tsx` - GitHub PR checks are green on `7d1b80f9702f20ab86cc502bffce599b13f1b088`. - Greptile confidence score is 5/5 and both Greptile review threads are resolved. ## Risks - Low risk. The change only affects UI classification of Paperclip artifact work products. The main behavioral risk is an uncommon text-like generated artifact no longer appearing in the Output section; it remains available through the normal attachment/document surfaces. > 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 GPT-5 Codex via Paperclip CodexCoder, with repository tool use and local command execution. ## 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 - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] 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>
292 lines
9.2 KiB
TypeScript
292 lines
9.2 KiB
TypeScript
import {
|
||
attachmentArtifactWorkProductMetadataSchema,
|
||
type AttachmentArtifactWorkProductMetadata,
|
||
type IssueWorkProduct,
|
||
} from "@paperclipai/shared";
|
||
|
||
/**
|
||
* Helpers + selectors for the issue Output surface (PAP-10162 Phase 3).
|
||
*
|
||
* The Output surface promotes attachment-backed artifact work products to a
|
||
* first-class slot on the issue page so cloud users can watch / download files
|
||
* an agent produced without digging through comments or the host filesystem.
|
||
*/
|
||
|
||
export type OutputFileTone = "video" | "pdf" | "zip" | "image" | "bin";
|
||
|
||
export interface OutputFileGlyph {
|
||
/** Short (≤4 char) label for the file-type tile, e.g. "MP4". */
|
||
label: string;
|
||
tone: OutputFileTone;
|
||
}
|
||
|
||
/**
|
||
* Format a byte count for display.
|
||
*
|
||
* Examples: `0 B`, `512 B`, `412 KB`, `18.4 MB`, `1.2 GB`. One decimal place is
|
||
* used from KB upward, with a trailing `.0` trimmed so round values stay clean.
|
||
*/
|
||
export function formatBytes(bytes: number): string {
|
||
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||
const units = ["KB", "MB", "GB", "TB"];
|
||
let value = bytes / 1024;
|
||
let unitIndex = 0;
|
||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||
value /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
const rounded = value.toFixed(1);
|
||
const trimmed = rounded.endsWith(".0") ? rounded.slice(0, -2) : rounded;
|
||
return `${trimmed} ${units[unitIndex]}`;
|
||
}
|
||
|
||
/**
|
||
* Format a duration in seconds as `m:ss` (under an hour) or `h:mm:ss`.
|
||
* Examples: `0:58`, `1:42:09`.
|
||
*/
|
||
export function formatDuration(seconds: number): string {
|
||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||
const total = Math.floor(seconds);
|
||
const hrs = Math.floor(total / 3600);
|
||
const mins = Math.floor((total % 3600) / 60);
|
||
const secs = total % 60;
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
if (hrs > 0) {
|
||
return `${hrs}:${pad(mins)}:${pad(secs)}`;
|
||
}
|
||
return `${mins}:${pad(secs)}`;
|
||
}
|
||
|
||
const GENERIC_BINARY_CONTENT_TYPES = new Set([
|
||
"application/octet-stream",
|
||
"binary/octet-stream",
|
||
"application/x-binary",
|
||
]);
|
||
|
||
const BINARY_OUTPUT_APPLICATION_TYPES = new Set([
|
||
"application/wasm",
|
||
]);
|
||
|
||
const ZIP_CONTENT_TYPES = new Set([
|
||
"application/zip",
|
||
"application/x-zip",
|
||
"application/x-zip-compressed",
|
||
]);
|
||
|
||
const MARKDOWN_CONTENT_TYPES = new Set([
|
||
"text/markdown",
|
||
"text/x-markdown",
|
||
"application/markdown",
|
||
"application/x-markdown",
|
||
]);
|
||
|
||
const DOCUMENT_LIKE_APPLICATION_TYPES = new Set([
|
||
"application/csv",
|
||
"application/ecmascript",
|
||
"application/graphql",
|
||
"application/html",
|
||
"application/javascript",
|
||
"application/json",
|
||
"application/ld+json",
|
||
"application/sql",
|
||
"application/toml",
|
||
"application/x-httpd-php",
|
||
"application/x-javascript",
|
||
"application/x-python",
|
||
"application/x-sh",
|
||
"application/x-yaml",
|
||
"application/xhtml+xml",
|
||
"application/xml",
|
||
"application/yaml",
|
||
]);
|
||
|
||
const DOCUMENT_LIKE_FILENAME_EXTENSIONS = [
|
||
".csv",
|
||
".css",
|
||
".graphql",
|
||
".htm",
|
||
".html",
|
||
".js",
|
||
".json",
|
||
".jsx",
|
||
".log",
|
||
".markdown",
|
||
".md",
|
||
".mdx",
|
||
".php",
|
||
".py",
|
||
".sh",
|
||
".sql",
|
||
".toml",
|
||
".ts",
|
||
".tsx",
|
||
".txt",
|
||
".xml",
|
||
".yaml",
|
||
".yml",
|
||
];
|
||
|
||
export function normalizeOutputContentType(contentType: string | null | undefined): string {
|
||
return (contentType ?? "").toLowerCase().split(";")[0]?.trim() ?? "";
|
||
}
|
||
|
||
function hasDocumentLikeFilename(originalFilename: string | null | undefined): boolean {
|
||
const filename = (originalFilename ?? "").trim().toLowerCase();
|
||
if (!filename) return false;
|
||
return DOCUMENT_LIKE_FILENAME_EXTENSIONS.some((extension) => filename.endsWith(extension));
|
||
}
|
||
|
||
function isZipContentType(contentType: string): boolean {
|
||
return ZIP_CONTENT_TYPES.has(contentType) || contentType.endsWith("+zip");
|
||
}
|
||
|
||
function isDocumentLikeOutputContentType(contentType: string): boolean {
|
||
if (!contentType) return false;
|
||
if (contentType.startsWith("text/")) return true;
|
||
if (MARKDOWN_CONTENT_TYPES.has(contentType)) return true;
|
||
if (DOCUMENT_LIKE_APPLICATION_TYPES.has(contentType)) return true;
|
||
if (contentType.startsWith("application/") && (contentType.endsWith("+json") || contentType.endsWith("+xml"))) return true;
|
||
return false;
|
||
}
|
||
|
||
export function isOutputEligibleContentType(
|
||
contentType: string | null | undefined,
|
||
originalFilename?: string | null | undefined,
|
||
): boolean {
|
||
const type = normalizeOutputContentType(contentType);
|
||
if (!type) return false;
|
||
if (isDocumentLikeOutputContentType(type)) return false;
|
||
if (GENERIC_BINARY_CONTENT_TYPES.has(type) && hasDocumentLikeFilename(originalFilename)) return false;
|
||
return (
|
||
type.startsWith("video/") ||
|
||
type.startsWith("image/") ||
|
||
type === "application/pdf" ||
|
||
BINARY_OUTPUT_APPLICATION_TYPES.has(type) ||
|
||
isZipContentType(type) ||
|
||
GENERIC_BINARY_CONTENT_TYPES.has(type)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Map a MIME type to a short label + tone for the 32×32 file-type tile.
|
||
*/
|
||
export function getOutputFileGlyph(contentType: string | null | undefined): OutputFileGlyph {
|
||
const type = normalizeOutputContentType(contentType);
|
||
if (type.startsWith("video/")) {
|
||
const subtype = type.slice("video/".length);
|
||
if (subtype === "quicktime") return { label: "MOV", tone: "video" };
|
||
return { label: (subtype || "vid").toUpperCase().slice(0, 4), tone: "video" };
|
||
}
|
||
if (type === "application/pdf") return { label: "PDF", tone: "pdf" };
|
||
if (isZipContentType(type)) {
|
||
return { label: "ZIP", tone: "zip" };
|
||
}
|
||
if (type.startsWith("image/")) return { label: "IMG", tone: "image" };
|
||
if (MARKDOWN_CONTENT_TYPES.has(type)) return { label: "MD", tone: "bin" };
|
||
if (type === "text/plain") return { label: "TXT", tone: "bin" };
|
||
if (type === "text/csv" || type === "application/csv") return { label: "CSV", tone: "bin" };
|
||
if (type === "text/html" || type === "application/html" || type === "application/xhtml+xml") {
|
||
return { label: "HTML", tone: "bin" };
|
||
}
|
||
if (type === "application/json" || type.endsWith("+json")) return { label: "JSON", tone: "bin" };
|
||
if (type === "application/xml" || type === "text/xml" || type.endsWith("+xml")) {
|
||
return { label: "XML", tone: "bin" };
|
||
}
|
||
if (type === "application/wasm") return { label: "WASM", tone: "bin" };
|
||
return { label: "BIN", tone: "bin" };
|
||
}
|
||
|
||
export function isVideoContentType(contentType: string | null | undefined): boolean {
|
||
return normalizeOutputContentType(contentType).startsWith("video/");
|
||
}
|
||
|
||
export function isImageContentType(contentType: string | null | undefined): boolean {
|
||
return normalizeOutputContentType(contentType).startsWith("image/");
|
||
}
|
||
|
||
/**
|
||
* A single rendered output. `metadata` is null when the work product's stored
|
||
* metadata fails validation — the row is still surfaced (degraded) so we never
|
||
* silently drop an artifact the agent reported producing.
|
||
*/
|
||
export interface IssueOutputItem {
|
||
id: string;
|
||
title: string;
|
||
status: string;
|
||
isPrimary: boolean;
|
||
createdAt: string | Date;
|
||
metadata: AttachmentArtifactWorkProductMetadata | null;
|
||
/** True when stored metadata could not be parsed into a usable artifact. */
|
||
degraded: boolean;
|
||
workProduct: IssueWorkProduct;
|
||
}
|
||
|
||
export interface IssueOutputs {
|
||
items: IssueOutputItem[];
|
||
primary: IssueOutputItem | null;
|
||
rest: IssueOutputItem[];
|
||
count: number;
|
||
}
|
||
|
||
function toTime(value: string | Date): number {
|
||
const t = new Date(value).getTime();
|
||
return Number.isNaN(t) ? 0 : t;
|
||
}
|
||
|
||
/**
|
||
* Parse attachment-backed artifact work products into renderable outputs.
|
||
*
|
||
* - Only `type: "artifact"` work products are considered outputs.
|
||
* - Metadata is validated against the shared contract; invalid metadata yields
|
||
* a degraded item rather than an exception.
|
||
* - Ordering: the explicit primary (or the most-recent artifact when none is
|
||
* marked primary) comes first, then remaining artifacts by most-recent.
|
||
*/
|
||
export function getIssueOutputs(workProducts: IssueWorkProduct[] | null | undefined): IssueOutputs {
|
||
const artifacts = (workProducts ?? []).filter((wp) => wp.type === "artifact" && wp.provider === "paperclip");
|
||
|
||
const items: IssueOutputItem[] = artifacts.flatMap((wp) => {
|
||
const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse(wp.metadata);
|
||
if (parsed.success && !isOutputEligibleContentType(parsed.data.contentType, parsed.data.originalFilename)) {
|
||
return [];
|
||
}
|
||
return [{
|
||
id: wp.id,
|
||
title: wp.title,
|
||
status: typeof wp.status === "string" ? wp.status : "active",
|
||
isPrimary: Boolean(wp.isPrimary),
|
||
createdAt: wp.createdAt,
|
||
metadata: parsed.success ? parsed.data : null,
|
||
degraded: !parsed.success,
|
||
workProduct: wp,
|
||
}];
|
||
});
|
||
|
||
items.sort((a, b) => {
|
||
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
|
||
return toTime(b.createdAt) - toTime(a.createdAt);
|
||
});
|
||
|
||
return {
|
||
items,
|
||
primary: items[0] ?? null,
|
||
rest: items.slice(1),
|
||
count: items.length,
|
||
};
|
||
}
|
||
|
||
/** Best display filename for an output, falling back to the work product title. */
|
||
export function outputFilename(item: IssueOutputItem): string {
|
||
return item.metadata?.originalFilename || item.title || "output";
|
||
}
|
||
|
||
export function getPromotedOutputAttachmentIds(workProducts: IssueWorkProduct[] | null | undefined): Set<string> {
|
||
return new Set(
|
||
getIssueOutputs(workProducts).items.flatMap((item) => {
|
||
const attachmentId = item.metadata?.attachmentId;
|
||
return attachmentId ? [attachmentId] : [];
|
||
}),
|
||
);
|
||
}
|