Files
paperclip/ui/storybook/stories/file-viewer.stories.tsx
T
Dotta 468edd8b22 Add workspace file viewer and artifact links (#7681)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent work is issue-centered, and reviewers often need to inspect
files, artifacts, and path references produced during that work.
> - Before this branch, workspace-relative paths and artifact file
references were not first-class inspectable objects in the board UI.
> - Safe file viewing needs shared resource contracts, server-side
workspace boundary checks, and UI that opens files without exposing
arbitrary host paths.
> - The workspace file viewer branch needed to stay as one active PR and
be rebased onto current `paperclipai/paperclip:master` for review.
> - This pull request adds the workspace file resource API, issue-page
file viewer and browser, markdown file-reference links, and artifact
file chips.
> - The benefit is that board users can inspect relevant files from
issue context while preserving workspace boundaries and auditability.

## Linked Issues or Issue Description

No public GitHub issue exists for this branch. Internal Paperclip
issues: `PAP-1953`, `PAP-10539`, `PAP-10733`.

Problem / motivation:
- Board users need to open workspace-relative files mentioned by agents
or attached as work-product metadata without switching to a terminal.
- The UI needs to support both direct file-path opening and workspace
browsing/searching from an issue page.
- The server must enforce company access, workspace boundaries, size
limits, rate limits, and safe audit logging.

Related PR:
- Prior closed attempt: #4442
- Single active PR for this branch: #7681

## What Changed

- Added shared workspace file resource types, validators, and
workspace-file `resourceRef` metadata validation for work products.
- Added server routes/services for resolving, listing, and previewing
workspace-relative files with access checks, scan caps, list-specific
limits, and audit logging.
- Added the issue file viewer provider, sheet, workspace browser,
command-palette action, markdown workspace-file autolinks, and artifact
file chips.
- Updated issue workspace UI and stories/tests for file browsing and
workspace file opening.
- Rebased the branch onto current `paperclipai/paperclip:master` and
updated the existing single PR branch.
- Addressed current-head Greptile follow-ups by applying `offset`
consistently across search/recent/changed file listings, restoring
stopped-service port ownership checks before auto-port reuse, and
stabilizing the workspace browser pagination test.

## Verification

Current local verification after rebase to `public/master`:
- `pnpm exec vitest run packages/shared/src/work-product.test.ts
server/src/__tests__/file-resources.test.ts
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/instance-settings-service.test.ts
server/src/__tests__/workspace-runtime.test.ts
ui/src/components/FileViewerSheet.test.tsx
ui/src/components/FileViewerSheet.copy.test.tsx
ui/src/components/WorkspaceFileBrowser.test.tsx
ui/src/components/WorkspaceFileMarkdownBody.test.tsx
ui/src/context/FileViewerContext.test.ts
ui/src/lib/remark-workspace-file-refs.test.ts
ui/src/lib/workspace-file-parser.test.ts
ui/src/components/IssueWorkspaceCard.test.tsx` - 13 files passed, 197
tests passed.
- `pnpm -r --filter @paperclipai/shared --filter @paperclipai/server
--filter @paperclipai/ui typecheck` - passed.
- `pnpm exec vitest run ui/src/components/WorkspaceFileBrowser.test.tsx`
- 1 file passed, 25 tests passed.
- `pnpm exec vitest run server/src/__tests__/file-resources.test.ts
server/src/__tests__/workspace-runtime.test.ts` - 2 files passed, 90
tests passed.
- `pnpm -r --filter @paperclipai/server typecheck` - passed.
- Confirmed branch is `0` behind and `46` ahead of current
`public/master` after rebase and follow-up commits.
- Confirmed the PR diff does not include `pnpm-lock.yaml`.
- Confirmed the PR diff does not include `.github/workflows` changes.
- Searched GitHub for duplicate or related workspace file viewer
PRs/issues; #4442 is the prior closed attempt and this PR is the single
active PR for the branch.
- No screenshots were committed; the task explicitly asked not to add
design screenshots or images unless they were part of the work.

Current remote verification on head
`a698a7bc10137baf7d25bd5722e1d6e0343387c1`:
- Greptile Review - success, 64 files reviewed, 0 comments added, no
unresolved Greptile review threads.
- PR workflow `verify` - success.
- Typecheck + Release Registry, General tests, workspace test shards,
serialized server suites, Build, Canary Dry Run, e2e, Socket, and Snyk -
success.
- `security-review` - neutral, with output saying a draft advisory was
filed for maintainer review and is not a merge block.
- `commitperclip PR Review / review` - cancelled after the security gate
detected flags and timed out while creating/reviewing the advisory. I
reran it once and it cancelled the same way; no actionable code/test
failure was exposed in the job logs.

## Risks

- This is a broad UI/server feature PR, so review needs to pay attention
to route authorization, workspace boundary handling, and markdown
autolink false positives.
- Workspace browsing intentionally caps list results and scan depth;
very large workspaces may require users to refine search terms.
- Remote workspace preview remains unavailable until remote file-access
support is implemented.
- The neutral commitperclip security-review advisory needs maintainer
review, but the check output says it is not a merge block.

> 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 in a Paperclip/Codex local tool-use
environment, medium reasoning, with shell/GitHub CLI tool use for branch
inspection, verification, rebase, PR update, Greptile review, and CI
inspection.

## 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
- [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
- [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>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 17:17:43 -05:00

524 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useLayoutEffect, useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useQueryClient } from "@tanstack/react-query";
import type {
ResolvedWorkspaceResource,
WorkspaceFileContent,
WorkspaceFileSelector,
} from "@paperclipai/shared";
import { FileViewerProvider } from "@/context/FileViewerContext";
import { FileViewerSheet } from "@/components/FileViewerSheet";
import { queryKeys } from "@/lib/queryKeys";
const ISSUE_ID = "issue-storybook-file-viewer";
const SAMPLE_CODE = `import { useMemo } from "react";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
export interface FileViewerSheetProps {
issueId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* Renders a right-side sheet that previews a workspace file.
* The viewer is URL-addressable via ?file=&line=&column=&workspace=.
*/
export function FileViewerSheet({
issueId,
open,
onOpenChange,
}: FileViewerSheetProps) {
const isOpen = typeof open === "boolean" ? open : true;
const classes = useMemo(
() =>
cn(
"flex h-full w-full flex-col gap-0 p-0",
"sm:max-w-[min(900px,90vw)]",
),
[],
);
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent side="right" className={classes}>
{/* header, metadata chips, content region */}
<p>Workspace file contents would render here.</p>
</SheetContent>
</Sheet>
);
}
`;
function buildResource(
overrides: Partial<ResolvedWorkspaceResource> = {},
): ResolvedWorkspaceResource {
return {
kind: "file",
provider: "git_worktree",
title: "FileViewerSheet.tsx",
displayPath: "ui/src/components/FileViewerSheet.tsx",
workspaceLabel: "PAP-1953 isolated worktree",
workspaceKind: "execution_workspace",
workspaceId: "ws-exec-pap-1953",
contentType: "text/tsx",
byteSize: SAMPLE_CODE.length,
previewKind: "text",
denialReason: null,
capabilities: { preview: true, download: false, listChildren: false },
...overrides,
};
}
function buildContent(
resource: ResolvedWorkspaceResource,
data = SAMPLE_CODE,
): WorkspaceFileContent {
return {
resource,
content: { encoding: "utf8", data },
};
}
function StoryFrame({ children }: { children: ReactNode }) {
return (
<div className="relative min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl space-y-4 p-6 text-sm">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Host issue page (preview)
</div>
<h1 className="text-xl font-semibold">
PAP-1963 Phase 4: UX visual review file viewer implementation
</h1>
<p className="text-muted-foreground">
This frame represents the underlying issue detail page so the sheet
overlay can be evaluated in context.
</p>
</div>
<div className="rounded-md border border-border bg-muted/40 p-4 text-xs text-muted-foreground">
The workspace file viewer opens as a right-side sheet, pushing a URL
entry. Use browser back to close.
</div>
</div>
{children}
</div>
);
}
interface HydratorProps {
seed?: (client: ReturnType<typeof useQueryClient>) => void;
stubFetch?: () => (() => void);
children: ReactNode;
}
function FileViewerHydrator({ seed, stubFetch, children }: HydratorProps) {
const queryClient = useQueryClient();
const [ready] = useState(() => {
seed?.(queryClient);
return true;
});
useLayoutEffect(() => {
if (!stubFetch) return;
const teardown = stubFetch();
return () => teardown();
}, [stubFetch]);
return ready ? <>{children}</> : null;
}
type ViewerRenderProps = {
path: string;
line?: number | null;
column?: number | null;
workspace?: WorkspaceFileSelector;
showPromptWhenEmpty?: boolean;
};
function FileViewerShell({
path,
line = null,
column = null,
workspace = "auto",
showPromptWhenEmpty = false,
seed,
stubFetch,
}: ViewerRenderProps & Omit<HydratorProps, "children">) {
return (
<FileViewerProvider issueId={ISSUE_ID}>
<FileViewerHydrator seed={seed} stubFetch={stubFetch}>
<StoryFrame>
<FileViewerSheet
issueId={ISSUE_ID}
state={
showPromptWhenEmpty
? null
: { path, line, column, workspace, projectId: null, workspaceId: null }
}
showPromptWhenEmpty={showPromptWhenEmpty}
open
/>
</StoryFrame>
</FileViewerHydrator>
</FileViewerProvider>
);
}
function seedContent(
queryClient: ReturnType<typeof useQueryClient>,
path: string,
workspace: WorkspaceFileSelector,
resource: ResolvedWorkspaceResource,
content: WorkspaceFileContent | null,
) {
queryClient.setQueryData(
queryKeys.issues.fileResource(ISSUE_ID, { path, workspace }),
resource,
);
if (content) {
queryClient.setQueryData(
queryKeys.issues.fileResourceContent(ISSUE_ID, { path, workspace }),
content,
);
}
}
function buildFetchStub(
resolver: (url: URL) => Response | Promise<Response> | null,
) {
return () => {
if (typeof window === "undefined") return () => undefined;
const originalFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const url = new URL(rawUrl, window.location.origin);
const maybe = resolver(url);
if (maybe) return maybe;
return originalFetch(input, init);
};
return () => {
window.fetch = originalFetch;
};
};
}
const meta: Meta = {
title: "Components/File Viewer Sheet",
component: FileViewerSheet,
parameters: {
layout: "fullscreen",
a11y: { test: "todo" },
},
};
export default meta;
type Story = StoryObj;
export const TextContentWithHighlightedLine: Story = {
name: "Text content — highlighted line",
render: () => {
const resource = buildResource();
const content = buildContent(resource);
return (
<FileViewerShell
path="ui/src/components/FileViewerSheet.tsx"
line={17}
column={3}
workspace="execution"
seed={(client) =>
seedContent(
client,
"ui/src/components/FileViewerSheet.tsx",
"execution",
resource,
content,
)
}
/>
);
},
};
export const TextContentLongPathTruncation: Story = {
name: "Text content — very long path",
render: () => {
const longPath =
"packages/backend/src/features/workspaces/resolvers/resolve-workspace-file-resource-from-issue-context.ts";
const resource = buildResource({
title: "resolve-workspace-file-resource-from-issue-context.ts",
displayPath: longPath,
});
const content = buildContent(resource, SAMPLE_CODE);
return (
<FileViewerShell
path={longPath}
line={42}
workspace="auto"
seed={(client) =>
seedContent(client, longPath, "auto", resource, content)
}
/>
);
},
};
export const OpenFilePrompt: Story = {
name: "Open file prompt (no selection)",
render: () => (
<FileViewerShell
path=""
workspace="auto"
showPromptWhenEmpty
/>
),
};
export const LoadingSpinner: Story = {
name: "Loading — 400ms+ spinner",
render: () => {
return (
<FileViewerShell
path="ui/src/components/FileViewerSheet.tsx"
workspace="auto"
stubFetch={buildFetchStub((url) => {
if (url.pathname.includes("/file-resources/")) {
return new Promise<Response>(() => {
/* never resolves — freezes query in pending */
});
}
return null;
})}
/>
);
},
};
export const ErrorNotFoundWithFallback: Story = {
name: "Error — file not found (project fallback)",
render: () => (
<FileViewerShell
path="missing/area/not-found.tsx"
workspace="execution"
stubFetch={buildFetchStub((url) => {
if (url.pathname.includes("/file-resources/resolve")) {
return Response.json(
{ error: "No file exists at that path.", code: "not_found" },
{ status: 404 },
);
}
return null;
})}
/>
),
};
export const ErrorNoWorkspace: Story = {
name: "Error — no workspace",
render: () => (
<FileViewerShell
path="ui/src/components/FileViewerSheet.tsx"
workspace="auto"
stubFetch={buildFetchStub((url) => {
if (url.pathname.includes("/file-resources/resolve")) {
return Response.json(
{
error: "This issue has no project or execution workspace.",
code: "no_workspace",
},
{ status: 422 },
);
}
return null;
})}
/>
),
};
export const ErrorOutsideWorkspace: Story = {
name: "Error — path outside workspace",
render: () => (
<FileViewerShell
path="../../../etc/passwd"
workspace="auto"
stubFetch={buildFetchStub((url) => {
if (url.pathname.includes("/file-resources/resolve")) {
return Response.json(
{
error: "Path escapes workspace root.",
code: "outside_workspace_root",
},
{ status: 400 },
);
}
return null;
})}
/>
),
};
export const ErrorDeniedSensitive: Story = {
name: "Error — denied by policy",
render: () => (
<FileViewerShell
path=".env.production"
workspace="auto"
stubFetch={buildFetchStub((url) => {
if (url.pathname.includes("/file-resources/resolve")) {
return Response.json(
{
error: "Blocked by workspace file policy.",
code: "denied_by_policy_sensitive",
},
{ status: 403 },
);
}
return null;
})}
/>
),
};
export const RemoteWorkspace: Story = {
name: "Remote workspace (preview unsupported)",
render: () => {
const resource = buildResource({
kind: "remote_resource",
provider: "remote_managed",
workspaceLabel: "Cloud worker (remote)",
workspaceKind: "execution_workspace",
previewKind: "unsupported",
capabilities: { preview: false, download: false, listChildren: false },
});
return (
<FileViewerShell
path="ui/src/components/FileViewerSheet.tsx"
workspace="auto"
seed={(client) =>
seedContent(
client,
"ui/src/components/FileViewerSheet.tsx",
"auto",
resource,
null,
)
}
/>
);
},
};
export const WorkspaceArchived: Story = {
name: "Workspace archived / cleaned up",
render: () => (
<FileViewerShell
path="ui/src/components/FileViewerSheet.tsx"
workspace="execution"
stubFetch={buildFetchStub((url) => {
if (url.pathname.includes("/file-resources/resolve")) {
return Response.json(
{
error: "This worktree has been cleaned up.",
code: "workspace_archived",
},
{ status: 410 },
);
}
return null;
})}
/>
),
};
export const BinaryUnsupported: Story = {
name: "Binary / unsupported file type",
render: () => {
const resource = buildResource({
title: "logo.woff2",
displayPath: "ui/public/fonts/logo.woff2",
contentType: "font/woff2",
previewKind: "unsupported",
capabilities: { preview: false, download: false, listChildren: false },
denialReason: "binary_unsupported",
});
return (
<FileViewerShell
path="ui/public/fonts/logo.woff2"
workspace="auto"
seed={(client) =>
seedContent(
client,
"ui/public/fonts/logo.woff2",
"auto",
resource,
null,
)
}
/>
);
},
};
export const TooLargeToPreview: Story = {
name: "Too large to preview",
render: () => {
const resource = buildResource({
title: "dataset.csv",
displayPath: "data/exports/dataset.csv",
contentType: "text/csv",
byteSize: 48_000_000,
previewKind: "unsupported",
capabilities: { preview: false, download: false, listChildren: false },
denialReason: "too_large",
});
return (
<FileViewerShell
path="data/exports/dataset.csv"
workspace="auto"
seed={(client) =>
seedContent(client, "data/exports/dataset.csv", "auto", resource, null)
}
/>
);
},
};
export const MobileView: Story = {
name: "Mobile — 390×844 highlighted line",
parameters: {
viewport: {
defaultViewport: "mobile",
},
},
render: () => {
const resource = buildResource();
const content = buildContent(resource);
return (
<FileViewerShell
path="ui/src/components/FileViewerSheet.tsx"
line={17}
column={3}
workspace="execution"
seed={(client) =>
seedContent(
client,
"ui/src/components/FileViewerSheet.tsx",
"execution",
resource,
content,
)
}
/>
);
},
};