From 4bad0cf17f8fc085c8b1bd3806ace67a6d68b4b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 21 Apr 2026 21:09:56 -0500 Subject: [PATCH] Build issue file viewer sheet UI (PAP-1962) Adds the Phase 3 UI for the workspace file viewer: - FileViewerProvider/FileViewerSheet pair, URL-addressable via ?file=/?line=/?column=/?workspace= so browser back closes or walks back through sheet history. - Remark plugin + parser that only auto-link path refs inside inline code spans (requires a slash or a line anchor to avoid prose matches like README.md). - Shared WorkspaceFileLink primitive used by markdown output; same primitive will be reusable for run output and work-product chips. - Issue toolbar "Open file" action, Command Palette entry "Open file in this issue...", and the `g f` keyboard route. - Designed state views for not found / outside workspace / denied / too large / binary / remote-only / workspace-archived / loading progressive states, with polite live-region announcements and a keyboard-scrollable code viewport. Tests cover the parser, the remark plugin, URL state reading/writing, the keyboard shortcut, and markdown-body workspace link rendering. Co-Authored-By: Paperclip --- ui/src/api/file-resources.ts | 34 + ui/src/components/CommandPalette.tsx | 23 +- ui/src/components/FileViewerSheet.tsx | 783 ++++++++++++++++++ ui/src/components/IssueChatThread.tsx | 1 + ui/src/components/MarkdownBody.test.tsx | 19 + ui/src/components/MarkdownBody.tsx | 24 + ui/src/components/WorkspaceFileLink.tsx | 68 ++ ui/src/context/FileViewerContext.test.ts | 81 ++ ui/src/context/FileViewerContext.tsx | 115 +++ ui/src/lib/keyboardShortcuts.test.ts | 15 + ui/src/lib/keyboardShortcuts.ts | 9 +- ui/src/lib/queryKeys.ts | 4 + ui/src/lib/remark-workspace-file-refs.test.ts | 86 ++ ui/src/lib/remark-workspace-file-refs.ts | 76 ++ ui/src/lib/workspace-file-parser.test.ts | Bin 0 -> 3148 bytes ui/src/lib/workspace-file-parser.ts | 83 ++ ui/src/pages/IssueDetail.tsx | 65 ++ 17 files changed, 1484 insertions(+), 2 deletions(-) create mode 100644 ui/src/api/file-resources.ts create mode 100644 ui/src/components/FileViewerSheet.tsx create mode 100644 ui/src/components/WorkspaceFileLink.tsx create mode 100644 ui/src/context/FileViewerContext.test.ts create mode 100644 ui/src/context/FileViewerContext.tsx create mode 100644 ui/src/lib/remark-workspace-file-refs.test.ts create mode 100644 ui/src/lib/remark-workspace-file-refs.ts create mode 100644 ui/src/lib/workspace-file-parser.test.ts create mode 100644 ui/src/lib/workspace-file-parser.ts diff --git a/ui/src/api/file-resources.ts b/ui/src/api/file-resources.ts new file mode 100644 index 00000000000..344cc7bb510 --- /dev/null +++ b/ui/src/api/file-resources.ts @@ -0,0 +1,34 @@ +import type { + ResolvedWorkspaceResource, + WorkspaceFileContent, + WorkspaceFileSelector, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export interface FileResourceQuery { + path: string; + workspace?: WorkspaceFileSelector; +} + +function buildQuery(query: FileResourceQuery): string { + const params = new URLSearchParams(); + params.set("path", query.path); + if (query.workspace && query.workspace !== "auto") { + params.set("workspace", query.workspace); + } + return params.toString(); +} + +export const fileResourcesApi = { + resolve(issueId: string, query: FileResourceQuery): Promise { + return api.get( + `/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`, + ); + }, + + content(issueId: string, query: FileResourceQuery): Promise { + return api.get( + `/issues/${encodeURIComponent(issueId)}/file-resources/content?${buildQuery(query)}`, + ); + }, +}; diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index a04bb30e99b..49689ebd9ed 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import { useNavigate } from "@/lib/router"; +import { useLocation, useNavigate } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -27,6 +27,7 @@ import { DollarSign, History, SquarePen, + FileCode2, Plus, Search, } from "lucide-react"; @@ -40,14 +41,22 @@ export function buildFullSearchPath(query: string) { return trimmed.length === 0 ? "/search" : `/search?q=${encodeURIComponent(trimmed)}`; } +const ISSUE_DETAIL_PATH_RE = /\/issues\/[^/?#]+(?:$|\?|#|\/)/; + +function isOnIssueDetail(pathname: string): boolean { + return ISSUE_DETAIL_PATH_RE.test(pathname); +} + export function CommandPalette() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const navigate = useNavigate(); + const location = useLocation(); const { selectedCompanyId } = useCompany(); const { openNewIssue, openNewAgent } = useDialogActions(); const { isMobile, setSidebarOpen } = useSidebar(); const searchQuery = query.trim(); + const onIssueDetail = isOnIssueDetail(location.pathname); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -177,6 +186,18 @@ export function CommandPalette() { Create new task C + {onIssueDetail && ( + { + setOpen(false); + window.dispatchEvent(new CustomEvent("paperclip:open-file-viewer")); + }} + > + + Open file in this issue... + g f + + )} { setOpen(false); diff --git a/ui/src/components/FileViewerSheet.tsx b/ui/src/components/FileViewerSheet.tsx new file mode 100644 index 00000000000..a5df7730187 --- /dev/null +++ b/ui/src/components/FileViewerSheet.tsx @@ -0,0 +1,783 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeft, + Ban, + Check, + Copy, + FileCode2, + FileSearch, + FolderOpen, + Loader2, + Lock, + RefreshCcw, +} from "lucide-react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { fileResourcesApi } from "@/api/file-resources"; +import { ApiError } from "@/api/client"; +import { queryKeys } from "@/lib/queryKeys"; +import { + useRequiredFileViewer, + type FileViewerUrlState, +} from "@/context/FileViewerContext"; +import type { + ResolvedWorkspaceResource, + WorkspaceFileContent, + WorkspaceFileSelector, +} from "@paperclipai/shared"; + +const FILE_VIEWER_LABELLED_BY_ID = "paperclip-file-viewer-title"; +const FILE_VIEWER_DESCRIBED_BY_ID = "paperclip-file-viewer-description"; + +interface FileViewerErrorShape { + status: number; + code: string; + message: string; +} + +function normalizeError(error: unknown): FileViewerErrorShape { + if (error instanceof ApiError) { + const body = (error.body ?? null) as { error?: string; code?: string } | null; + const code = typeof body?.code === "string" ? body.code : ""; + return { + status: error.status, + code, + message: typeof body?.error === "string" ? body.error : error.message, + }; + } + if (error instanceof Error) { + return { status: 0, code: "", message: error.message }; + } + return { status: 0, code: "", message: "Something went wrong." }; +} + +function formatBytes(size: number | null | undefined): string | null { + if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return null; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function decodeBase64ToBytes(data: string): Uint8Array { + const raw = globalThis.atob ? globalThis.atob(data) : ""; + const bytes = new Uint8Array(raw.length); + for (let index = 0; index < raw.length; index += 1) { + bytes[index] = raw.charCodeAt(index); + } + return bytes; +} + +function bytesToDataUrl(bytes: Uint8Array, contentType: string | null | undefined): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + const encoded = globalThis.btoa ? globalThis.btoa(binary) : ""; + return `data:${contentType ?? "application/octet-stream"};base64,${encoded}`; +} + +function splitContentIntoLines(data: string): string[] { + if (data === "") return [""]; + const normalized = data.replace(/\r\n?/g, "\n"); + const lines = normalized.split("\n"); + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + return lines.length > 0 ? lines : [""]; +} + +function basename(path: string): string { + const idx = path.lastIndexOf("/"); + return idx < 0 ? path : path.slice(idx + 1); +} + +function middleTruncatePath(path: string, maxLen = 80): string { + if (path.length <= maxLen) return path; + const head = path.slice(0, Math.floor(maxLen / 2) - 1); + const tail = path.slice(path.length - (maxLen - head.length - 1)); + return `${head}…${tail}`; +} + +function describeDenial(code: string, fallback: string): { title: string; body: string; icon: ReactNode } { + const lower = code.toLowerCase(); + if (lower.includes("policy") || lower.includes("denied") || lower.includes("sensitive")) { + return { + icon: