mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 04:48:34 +02:00
## Thinking Path > - Paperclip helps people manage AI agents and inspect their work. > - Task file tabs show the files that agents produce. > - Markdown has a readable view, but CSV files show only source text. > - People need to scan CSV reports without a separate app. > - This pull request adds a table view to attachment and workspace file tabs. > - View buttons let people inspect the original source beside download. ## Linked Issues or Issue Description **Subsystem affected** ui/ — task file tabs. **Problem or motivation** CSV exports are difficult to scan as source text. Related work: https://github.com/paperclipai/paperclip/pull/14297 added text attachment tabs. **Proposed solution** Render CSV as a table by default. Add row numbers, sticky headers, and record counts. Keep rendered and raw icons beside download in task tabs. **Roadmap alignment** This is a small improvement to existing task file previews. It does not add a new core subsystem. ## What Changed - Add a shared CSV parser and table preview. - Handle quoted commas, escaped quotes, UTF-8 BOM, CRLF, and multiline cells. - Show at most 500 data rows and 100 columns. Show a notice for larger files. Keep the complete download. - Add CSV attachment and workspace previews. Put workspace tab view controls in the header. - Add parser and interaction tests, Storybook examples, and product documentation. ## Verification - 28 focused tests pass across the parser, attachment tabs, and file viewer. - UI typecheck, UI build, and token gates pass. - Playwright checked desktop and mobile rendered → raw → rendered flows using the real attachment preview component in Storybook. Screenshots are recorded on the driving task. - Repository-wide typecheck and build were attempted. Both require cargo, which is absent in this environment. - The earlier repository-wide local test run stopped after a server test race failure. The prior commit passed all GitHub CI jobs. - Review fixes add source-line navigation coverage and CSV boundary tests. All 22 file-viewer tests pass. UI typecheck and token gates pass. - All 54 active GitHub checks pass on the final commit. Two Storybook checks are skipped. Greptile gives 5/5 with no actionable findings. - The final version preserves HTML previews from master and uses the shared toolbar control. All 35 focused tests, UI typecheck, UI build, and token gates pass. ## Risks - The first CSV record supplies column headers. Files without headers use that same rule. - The table preview is bounded. Download and raw view retain the complete file. - No API, database, or dependency changes. ## Model Used - OpenAI Codex, GPT-6. The runtime does not expose a more precise model ID or context window. Used code editing, shell tools, and browser checks. ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [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>
112 lines
4.8 KiB
TypeScript
112 lines
4.8 KiB
TypeScript
import { isCsvFile } from "@/lib/csv-preview";
|
|
import { useEffect, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Check, Copy, Download } from "lucide-react";
|
|
import { fileResourcesApi } from "@/api/file-resources";
|
|
import { FileViewerBody, FileViewerMetadataRow } from "@/components/FileViewerSheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle";
|
|
import { isHtmlPreview } from "@/lib/html-preview";
|
|
import type { FileViewerUrlState } from "@/context/FileViewerContext";
|
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import type { TaskSidePanelTabPayload } from "@/lib/task-side-panel-state";
|
|
|
|
type WorkspaceFilePayload = Extract<TaskSidePanelTabPayload, { kind: "workspace-file" }>;
|
|
|
|
export function TaskWorkspaceFilePanel({
|
|
issueId,
|
|
payload,
|
|
onFallbackToProject,
|
|
}: {
|
|
issueId: string;
|
|
payload: WorkspaceFilePayload;
|
|
onFallbackToProject?: () => void;
|
|
}) {
|
|
const [previewMode, setPreviewMode] = useState<"raw" | "rendered">("rendered");
|
|
useEffect(() => setPreviewMode("rendered"), [payload.path, payload.workspace, payload.workspaceId, payload.projectId]);
|
|
const [copied, setCopied] = useState(false);
|
|
const [announcement, setAnnouncement] = useState("");
|
|
const state: FileViewerUrlState = {
|
|
path: payload.path,
|
|
workspace: payload.workspace,
|
|
projectId: payload.projectId,
|
|
workspaceId: payload.workspaceId,
|
|
line: payload.line,
|
|
column: payload.column,
|
|
};
|
|
const resourceQuery = useQuery({
|
|
queryKey: queryKeys.issues.fileResource(issueId, state),
|
|
queryFn: () => fileResourcesApi.resolve(issueId, state),
|
|
retry: false,
|
|
});
|
|
const resource = resourceQuery.data;
|
|
const contentQuery = useQuery({
|
|
queryKey: queryKeys.issues.fileResourceContent(issueId, state),
|
|
queryFn: () => fileResourcesApi.content(issueId, state),
|
|
enabled: resource?.capabilities.preview === true,
|
|
retry: false,
|
|
});
|
|
const downloadUrl = resource?.capabilities.download
|
|
? fileResourcesApi.downloadUrl(issueId, state)
|
|
: null;
|
|
|
|
const renderedPreview = resource && (isHtmlPreview(resource.contentType, resource.displayPath || resource.title) || isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "") || /\.(md|markdown|mdown|mkdn|mkd)$/i.test(resource.displayPath || resource.title) || resource.contentType?.includes("markdown"));
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col bg-muted/20">
|
|
<header className="flex min-w-0 items-start gap-2 border-b border-border bg-card px-3 py-2">
|
|
<div className="min-w-0 flex-1">
|
|
<h2 className="truncate text-sm font-medium" title={payload.path}>{resource?.title ?? payload.path}</h2>
|
|
<p className="truncate font-mono text-xs text-muted-foreground" title={payload.path}>{payload.path}</p>
|
|
<FileViewerMetadataRow resolvedResource={resource} state={state} />
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{contentQuery.data ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Copy file contents"
|
|
title={copied ? "Copied" : "Copy file contents"}
|
|
onClick={() => {
|
|
void copyTextToClipboard(contentQuery.data!.content.data).then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1800);
|
|
});
|
|
}}
|
|
>
|
|
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
|
|
</Button>
|
|
) : null}
|
|
{renderedPreview && contentQuery.data ? <FilePreviewModeToggle mode={previewMode} onChange={setPreviewMode} label="File preview mode" /> : null}
|
|
{downloadUrl ? (
|
|
<Button asChild variant="ghost" size="icon-sm">
|
|
<a href={downloadUrl} download={resource?.title} aria-label="Download file" title="Download file">
|
|
<Download aria-hidden />
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<div className="sr-only" aria-live="polite">{announcement}</div>
|
|
<FileViewerBody
|
|
previewMode={previewMode}
|
|
resolveQuery={resourceQuery}
|
|
contentQuery={contentQuery}
|
|
elapsedMs={0}
|
|
canPreview={resource?.capabilities.preview ?? false}
|
|
highlightedLine={payload.line}
|
|
onRetry={() => {
|
|
void resourceQuery.refetch();
|
|
if (resource?.capabilities.preview) void contentQuery.refetch();
|
|
}}
|
|
onSetAnnouncement={setAnnouncement}
|
|
onFallbackToProject={onFallbackToProject ?? null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|