Rework workspace browser to match PAP-10508 design (PAP-10511)

Addresses UXDesigner review against the approved design spec:

- Placement: move the discovery affordance into a calm action row at the
  bottom of IssueWorkspaceCard ("Browse files…" + "Open file by path…"),
  removing the orphaned button between Artifacts and Tabs. The existing
  top-right toolbar / `g f` entry stays as the secondary affordance.
- Single sheet, two modes: opening a result from browse now shows a
  "← Back to files" affordance (and a separate × close) in the view header,
  with Esc returning to the list. Browse state is URL-backed (`browse=1`,
  `q`) via FileViewerContext (open(...{fromBrowse}), openBrowse, backToFiles).
- Remove the Recent/All toggle: empty query → Recently changed (silent
  fallback to full listing when git change-tracking is unavailable);
  populated query → Files matching. Search field accepts `path:line` and
  Enter opens the highlighted row or the typed path.
- Mobile (<sm) two-line rows (basename / dirname, no timestamp, 44px tap
  targets); desktop single-line with dir-truncate. Recently-changed shows
  relative timestamps; search results omit them to maximise path width.
- Keyboard: listbox/option roles, ↑/↓ + Enter, aria-activedescendant,
  combobox search field.
- Align unavailable copy with describeDenial (remote / cleaned-up);
  debounce 150ms; documented the 100-row cap.
- Stories now render the real FileViewerSheet / IssueWorkspaceCard for
  truthful light+dark, desktop+360px, view-mode, and placement captures.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent 8569315d74
commit 0581aa1256
7 files changed
+576 -451

No files matched your search

+69 -131
View File
@@ -9,6 +9,7 @@ import {
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
Ban,
Check,
Cloud,
@@ -16,10 +17,12 @@ import {
FileCode2,
FileSearch,
FolderOpen,
FolderSearch,
Link2,
Loader2,
Lock,
RefreshCcw,
X,
} from "lucide-react";
import {
Sheet,
@@ -37,7 +40,6 @@ import {
useRequiredFileViewer,
type FileViewerUrlState,
} from "@/context/FileViewerContext";
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import type {
ResolvedWorkspaceResource,
@@ -178,112 +180,6 @@ function FileViewerStateView({
);
}
interface OpenFilePromptProps {
onSubmit: (
path: string,
workspace: WorkspaceFileSelector,
line: number | null,
column: number | null,
) => void;
}
function OpenFilePrompt({ onSubmit }: OpenFilePromptProps) {
const [value, setValue] = useState("");
const [workspace, setWorkspace] = useState<WorkspaceFileSelector>("auto");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, []);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed) return;
const parsed = parseWorkspaceFileRef(trimmed);
if (parsed) {
onSubmit(parsed.path, workspace, parsed.line, parsed.column);
return;
}
onSubmit(trimmed, workspace, null, null);
};
return (
<form className="space-y-4 p-6" onSubmit={handleSubmit}>
<label htmlFor="paperclip-file-viewer-input" className="sr-only">
Workspace-relative file path
</label>
<input
ref={inputRef}
id="paperclip-file-viewer-input"
type="text"
className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="e.g. ui/src/pages/IssueDetail.tsx:42"
value={value}
onChange={(event) => setValue(event.target.value)}
autoComplete="off"
spellCheck={false}
/>
<fieldset className="space-y-2">
<legend className="text-xs text-muted-foreground">Workspace</legend>
<div className="flex flex-wrap gap-2">
{(["auto", "execution", "project"] as const).map((option) => (
<label
key={option}
className={cn(
"cursor-pointer rounded-md border px-3 py-1 text-xs capitalize",
workspace === option
? "border-primary bg-primary/10 text-primary"
: "border-border text-foreground/80 hover:bg-accent/40",
)}
>
<input
type="radio"
name="paperclip-file-viewer-workspace"
value={option}
checked={workspace === option}
onChange={() => setWorkspace(option)}
className="sr-only"
/>
{option === "auto" ? "Auto (issue default)" : option}
</label>
))}
</div>
</fieldset>
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={!value.trim()}>
Open file
</Button>
</div>
</form>
);
}
interface FileViewerEmptyStateProps {
issueId: string;
onBrowseOpen: (ref: { path: string; workspace: WorkspaceFileSelector }) => void;
onSubmitPath: OpenFilePromptProps["onSubmit"];
}
function FileViewerEmptyState({ issueId, onBrowseOpen, onSubmitPath }: FileViewerEmptyStateProps) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<WorkspaceFileBrowser
issueId={issueId}
onOpen={onBrowseOpen}
className="min-h-0 flex-1 p-4 pb-2"
/>
<details className="border-t border-border">
<summary className="cursor-pointer list-none px-4 py-2.5 text-xs text-muted-foreground hover:text-foreground">
Open a specific path…
</summary>
<OpenFilePrompt onSubmit={onSubmitPath} />
</details>
</div>
);
}
interface FileContentViewerProps {
content: WorkspaceFileContent;
highlightedLine: number | null;
@@ -438,7 +334,12 @@ export function FileViewerSheet({
}: FileViewerSheetProps) {
const viewer = useRequiredFileViewer();
const state = typeof stateProp !== "undefined" ? stateProp : viewer.state;
const computedOpen = typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty;
// Browse mode: no file selected, but the sheet was opened to browse/search.
const browseMode = state === null && (showPromptWhenEmpty || viewer.browse);
// True when the current file was reached from the browse list (drill-down).
const cameFromBrowse = state !== null && viewer.browse;
const computedOpen =
typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty || viewer.browse;
const [elapsedMs, setElapsedMs] = useState(0);
const [copiedField, setCopiedField] = useState<"path" | "link" | null>(null);
@@ -506,14 +407,12 @@ export function FileViewerSheet({
[onOpenChange, viewer],
);
const handlePromptSubmit = useCallback(
(
path: string,
workspace: WorkspaceFileSelector,
line: number | null,
column: number | null,
) => {
viewer.open({ path, line, column, workspace });
const handleBrowseOpen = useCallback(
(ref: { path: string; workspace: WorkspaceFileSelector; line?: number | null; column?: number | null }) => {
viewer.open(
{ path: ref.path, line: ref.line ?? null, column: ref.column ?? null, workspace: ref.workspace },
{ fromBrowse: true },
);
},
[viewer],
);
@@ -556,10 +455,21 @@ export function FileViewerSheet({
aria-labelledby={FILE_VIEWER_LABELLED_BY_ID}
aria-describedby={FILE_VIEWER_DESCRIBED_BY_ID}
showCloseButton={false}
onEscapeKeyDown={(event) => {
// From a file reached via browse, Esc returns to the list; a second Esc closes.
if (cameFromBrowse) {
event.preventDefault();
viewer.backToFiles();
}
}}
>
<SheetHeader className="border-b border-border gap-1 p-3">
<div className="flex items-start gap-2">
<FileCode2 aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
{browseMode ? (
<FolderSearch aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileCode2 aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<SheetTitle id={FILE_VIEWER_LABELLED_BY_ID} className="truncate text-sm">
{title}
@@ -576,6 +486,19 @@ export function FileViewerSheet({
</SheetDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
{cameFromBrowse ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => viewer.backToFiles()}
className="h-7 gap-1 px-2 text-xs"
aria-label="Back to files"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to files
</Button>
) : null}
{state ? (
<Button
type="button"
@@ -602,16 +525,30 @@ export function FileViewerSheet({
{copiedField === "link" ? <Check className="h-4 w-4 text-green-500" /> : <Link2 className="h-4 w-4" />}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleOpenChange(false)}
className="h-7 px-2 text-xs"
aria-label="Close file viewer"
>
Close
</Button>
{cameFromBrowse ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => handleOpenChange(false)}
className="h-7 w-7"
aria-label="Close file viewer"
title="Close"
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleOpenChange(false)}
className="h-7 px-2 text-xs"
aria-label="Close file viewer"
>
Close
</Button>
)}
</div>
</div>
{resolvedResource ? (
@@ -668,11 +605,12 @@ export function FileViewerSheet({
: null
}
/>
) : showPromptWhenEmpty ? (
<FileViewerEmptyState
) : browseMode ? (
<WorkspaceFileBrowser
issueId={issueId}
onBrowseOpen={(ref) => viewer.open({ path: ref.path, line: null, column: null, workspace: ref.workspace })}
onSubmitPath={handlePromptSubmit}
onOpen={handleBrowseOpen}
initialQuery={viewer.query}
className="min-h-0 flex-1 p-4"
/>
) : null}
</div>
+29 -1
View File
@@ -10,7 +10,7 @@ import { queryKeys } from "../lib/queryKeys";
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
import { cn, projectWorkspaceUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
import { Check, Copy, FileSearch, FolderOpen, FolderSearch, GitBranch, Pencil, X } from "lucide-react";
/* -------------------------------------------------------------------------- */
/* Utility helpers (mirrored from IssueProperties for self-containment) */
@@ -192,6 +192,10 @@ interface IssueWorkspaceCardProps {
initialEditing?: boolean;
livePreview?: boolean;
onDraftChange?: (data: Record<string, unknown>, meta: { canSave: boolean; workspaceBranchName?: string | null }) => void;
/** Opens the workspace file browser sheet. When omitted, the browse row is hidden. */
onBrowseFiles?: () => void;
/** Opens the same browser sheet focused for path entry. */
onOpenFileByPath?: () => void;
}
export function IssueWorkspaceCard({
@@ -201,6 +205,8 @@ export function IssueWorkspaceCard({
initialEditing = false,
livePreview = false,
onDraftChange,
onBrowseFiles,
onOpenFileByPath,
}: IssueWorkspaceCardProps) {
const { selectedCompanyId } = useCompany();
const companyId = issue.companyId ?? selectedCompanyId;
@@ -572,6 +578,28 @@ export function IssueWorkspaceCard({
)}
</div>
)}
{/* Workspace file discovery — calm row under the workspace identity. */}
{!showEditingControls && onBrowseFiles && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border/50 pt-2 text-xs">
<button
type="button"
onClick={onBrowseFiles}
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<FolderSearch className="h-3.5 w-3.5 shrink-0" />
Browse files…
</button>
<button
type="button"
onClick={onOpenFileByPath ?? onBrowseFiles}
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<FileSearch className="h-3.5 w-3.5 shrink-0" />
Open file by path…
</button>
</div>
)}
</div>
);
}
+50 -56
View File
@@ -31,14 +31,14 @@ function createItem(overrides: Partial<WorkspaceFileListItem> = {}): WorkspaceFi
workspaceId: "ws-1",
contentType: "text/plain; charset=utf-8",
byteSize: 2048,
modifiedAt: null,
modifiedAt: new Date(Date.now() - 120_000).toISOString(),
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
...overrides,
};
}
function availableResponse(items: WorkspaceFileListItem[]): WorkspaceFileListResponse {
function availableResponse(items: WorkspaceFileListItem[], truncated = false): WorkspaceFileListResponse {
return {
kind: "workspace_file_list",
state: "available",
@@ -51,7 +51,7 @@ function availableResponse(items: WorkspaceFileListItem[]): WorkspaceFileListRes
query: { workspace: "auto", mode: "changed", q: null, limit: 100 },
items,
scannedCount: items.length,
truncated: false,
truncated,
};
}
@@ -68,6 +68,10 @@ function unavailableResponse(reason: string): WorkspaceFileListResponse {
};
}
function ok(data: WorkspaceFileListResponse) {
return { data, isFetching: false, isError: false, error: null, refetch: vi.fn() };
}
describe("WorkspaceFileBrowser", () => {
let container: HTMLDivElement;
@@ -89,82 +93,72 @@ describe("WorkspaceFileBrowser", () => {
return { root, onOpen };
}
it("lists available files and opens one with its relative path and workspace selector", () => {
useQueryMock.mockReturnValue({
data: availableResponse([createItem(), createItem({ relativePath: "README.md", displayPath: "README.md" })]),
isFetching: false,
isError: false,
error: null,
refetch: vi.fn(),
});
it("renders the Recently changed list as a listbox and opens a row with its relative path", () => {
useQueryMock.mockReturnValue(
ok(availableResponse([createItem(), createItem({ relativePath: "README.md", displayPath: "README.md" })])),
);
const { onOpen } = renderBrowser();
expect(container.querySelector('[role="listbox"]')).not.toBeNull();
expect(container.textContent).toContain("Recently changed");
expect(container.textContent).toContain("From Isolated workspace");
const fileButton = Array.from(container.querySelectorAll("button")).find((b) =>
b.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
const option = Array.from(container.querySelectorAll('[role="option"]')).find(
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
);
expect(fileButton).not.toBeUndefined();
expect(option).not.toBeUndefined();
act(() => {
fileButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onOpen).toHaveBeenCalledWith({ path: "ui/src/pages/IssueDetail.tsx", workspace: "auto" });
});
it("shows the remote-workspace state without any file rows", () => {
useQueryMock.mockReturnValue({
data: unavailableResponse("remote_workspace"),
isFetching: false,
isError: false,
error: null,
refetch: vi.fn(),
});
it("does not render a Recent/All toggle", () => {
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
renderBrowser();
expect(container.textContent).toContain("Remote workspace");
expect(container.textContent).not.toContain("From Isolated workspace");
expect(container.textContent).not.toContain("All files");
expect(container.textContent).not.toContain("Recent changes / All");
});
it("discloses truncation in the footer", () => {
useQueryMock.mockReturnValue(ok(availableResponse([createItem()], true)));
renderBrowser();
expect(container.textContent).toContain("refine the search to narrow");
});
it("opens the highlighted row when Enter is pressed in the search field", () => {
useQueryMock.mockReturnValue(ok(availableResponse([createItem()])));
const { onOpen } = renderBrowser();
const input = container.querySelector("input")!;
act(() => {
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
});
expect(onOpen).toHaveBeenCalledWith({ path: "ui/src/pages/IssueDetail.tsx", workspace: "auto" });
});
it("shows the remote-workspace state without file rows", () => {
useQueryMock.mockReturnValue(ok(unavailableResponse("remote_workspace")));
renderBrowser();
expect(container.textContent).toContain("Remote workspace preview not supported");
expect(container.querySelector('[role="listbox"]')).toBeNull();
});
it("shows the no-workspace state when the issue has no workspace", () => {
useQueryMock.mockReturnValue({
data: unavailableResponse("no_workspace"),
isFetching: false,
isError: false,
error: null,
refetch: vi.fn(),
});
useQueryMock.mockReturnValue(ok(unavailableResponse("no_workspace")));
renderBrowser();
expect(container.textContent).toContain("No workspace yet");
});
it("offers a fallback to all files when recent-change tracking is unavailable", () => {
useQueryMock.mockReturnValue({
data: unavailableResponse("changed_unavailable"),
isFetching: false,
isError: false,
error: null,
refetch: vi.fn(),
});
renderBrowser();
expect(container.textContent).toContain("Recent changes unavailable");
const fallback = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Browse all files"),
);
expect(fallback).not.toBeUndefined();
});
});
describe("describeUnavailable", () => {
it("maps each documented reason to a distinct, human-readable title", () => {
expect(describeUnavailable("remote_workspace").title).toBe("Remote workspace");
it("maps reasons to copy that matches the viewer's denial voice", () => {
expect(describeUnavailable("remote_workspace").title).toBe("Remote workspace preview not supported");
expect(describeUnavailable("no_workspace").title).toBe("No workspace yet");
expect(describeUnavailable("no_local_workspace").title).toBe("No workspace yet");
expect(describeUnavailable("workspace_unavailable").title).toBe("Workspace cleaned up");
expect(describeUnavailable("changed_unavailable").title).toBe("Recent changes unavailable");
expect(describeUnavailable("workspace_unavailable").title).toBe("Workspace is no longer available");
expect(describeUnavailable("archived").title).toBe("Workspace is no longer available");
});
it("never leaks the raw reason code as the body", () => {
+191 -170
View File
@@ -1,20 +1,21 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
Cloud,
FileCode2,
FolderOpen,
FolderSearch,
Loader2,
Search,
} from "lucide-react";
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { fileResourcesApi } from "@/api/file-resources";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
import type {
WorkspaceFileListItem,
WorkspaceFileListMode,
@@ -27,20 +28,11 @@ const WORKSPACE_OPTIONS: ReadonlyArray<{ value: WorkspaceFileSelector; label: st
{ value: "project", label: "Project" },
];
const MODE_OPTIONS: ReadonlyArray<{ value: Extract<WorkspaceFileListMode, "changed" | "all">; label: string }> = [
{ value: "changed", label: "Recent changes" },
{ value: "all", label: "All files" },
];
// Hard list cap. The spec called out ~50 to keep reads cheap; 100 trades a bit
// more scan for fewer "refine to narrow" dead-ends on large trees. Footer always
// discloses truncation so the cap is never silent.
const LIST_LIMIT = 100;
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 basename(path: string): string {
const idx = path.lastIndexOf("/");
return idx < 0 ? path : path.slice(idx + 1);
@@ -52,17 +44,18 @@ function dirname(path: string): string {
}
/**
* Maps a server `unavailableReason` (or transport error) to a calm, board-readable
* explanation. Substring matching keeps it resilient to small reason-string changes
* on the server, mirroring describeDenial() in FileViewerSheet.
* Maps a server `unavailableReason` to a calm, board-readable explanation.
* Copy is kept in sync with `describeDenial` in FileViewerSheet so the browse
* states and the viewer's error panels read in one voice. Substring matching
* keeps it resilient to small reason-string changes on the server.
*/
export function describeUnavailable(reason: string): { title: string; body: string; icon: ReactNode } {
const lower = reason.toLowerCase();
if (lower.includes("remote")) {
return {
icon: <Cloud aria-hidden="true" className="h-5 w-5 text-muted-foreground" />,
title: "Remote workspace",
body: "This issue runs in a remote workspace, so its files can't be browsed here yet.",
title: "Remote workspace preview not supported",
body: "This workspace is hosted remotely and is not available for inline preview yet.",
};
}
if (lower.includes("no_workspace") || lower.includes("no_local")) {
@@ -72,18 +65,11 @@ export function describeUnavailable(reason: string): { title: string; body: stri
body: "This issue does not have a workspace to browse. Files appear here once a run creates one.",
};
}
if (lower.includes("changed")) {
return {
icon: <AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />,
title: "Recent changes unavailable",
body: "Recent-change tracking isn't available for this workspace. Browse all files instead.",
};
}
if (lower.includes("archiv") || lower.includes("cleaned") || lower.includes("unavailable")) {
return {
icon: <FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />,
title: "Workspace cleaned up",
body: "The workspace for this issue has been cleaned up, so its files can no longer be browsed.",
title: "Workspace is no longer available",
body: "The isolated worktree for this issue has been cleaned up, so files cannot be previewed.",
};
}
return {
@@ -93,39 +79,28 @@ export function describeUnavailable(reason: string): { title: string; body: stri
};
}
function StateMessage({ icon, title, body, action }: { icon: ReactNode; title: string; body?: string; action?: ReactNode }) {
function StateMessage({ icon, title, body }: { icon: ReactNode; title: string; body?: string }) {
return (
<div className="flex flex-col items-start gap-3 px-1 py-8 text-sm">
<div className="flex items-start gap-3">
{icon}
<div className="space-y-1">
<p className="font-medium text-foreground">{title}</p>
{body ? <p className="text-muted-foreground">{body}</p> : null}
</div>
<div className="flex items-start gap-3 px-1 py-8 text-sm">
{icon}
<div className="space-y-1">
<p className="font-medium text-foreground">{title}</p>
{body ? <p className="text-muted-foreground">{body}</p> : null}
</div>
{action}
</div>
);
}
function SegmentedControl<T extends string>({
label,
function WorkspaceSelector({
value,
options,
onChange,
}: {
label: string;
value: T;
options: ReadonlyArray<{ value: T; label: string }>;
onChange: (next: T) => void;
value: WorkspaceFileSelector;
onChange: (next: WorkspaceFileSelector) => void;
}) {
return (
<div
role="group"
aria-label={label}
className="inline-flex shrink-0 rounded-md border border-border p-0.5"
>
{options.map((option) => (
<div role="group" aria-label="Workspace" className="inline-flex shrink-0 rounded-md border border-border p-0.5">
{WORKSPACE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
@@ -133,9 +108,7 @@ function SegmentedControl<T extends string>({
onClick={() => onChange(option.value)}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
value === option.value
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
value === option.value ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
@@ -147,74 +120,116 @@ function SegmentedControl<T extends string>({
interface WorkspaceFileRowProps {
item: WorkspaceFileListItem;
onOpen: (item: WorkspaceFileListItem) => void;
optionId: string;
selected: boolean;
showTimestamp: boolean;
onOpen: () => void;
onHover: () => void;
}
function WorkspaceFileRow({ item, onOpen }: WorkspaceFileRowProps) {
function WorkspaceFileRow({ item, optionId, selected, showTimestamp, onOpen, onHover }: WorkspaceFileRowProps) {
const name = basename(item.displayPath);
const dir = dirname(item.displayPath);
const size = formatBytes(item.byteSize);
const stamp = showTimestamp && item.modifiedAt ? timeAgo(item.modifiedAt) : null;
return (
<button
type="button"
onClick={() => onOpen(item)}
<div
id={optionId}
role="option"
aria-selected={selected}
onClick={onOpen}
onMouseEnter={onHover}
title={item.displayPath}
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={cn(
"flex min-h-[44px] cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 sm:min-h-0",
selected ? "bg-accent" : "hover:bg-accent/60",
)}
>
<FileCode2 aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-1 items-baseline gap-0 overflow-hidden">
{/* Desktop (>=sm): single line, directory truncates and basename stays visible. */}
<span className="hidden min-w-0 flex-1 items-baseline gap-0 overflow-hidden sm:flex">
{dir ? (
<span className="min-w-0 shrink truncate font-mono text-xs text-muted-foreground">
{dir}
</span>
<span className="min-w-0 shrink truncate font-mono text-xs text-muted-foreground">{dir}</span>
) : null}
<span className="shrink-0 truncate font-mono text-xs text-foreground">{name}</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
<span className="capitalize">{item.previewKind}</span>
{size ? <span className="tabular-nums">{size}</span> : null}
{/* Mobile (<sm): two lines, basename first so the filename is always readable. */}
<span className="flex min-w-0 flex-1 flex-col overflow-hidden sm:hidden">
<span className="truncate font-mono text-xs text-foreground">{name}</span>
{dir ? (
<span className="truncate font-mono text-[11px] text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
{dir}
</span>
) : null}
</span>
</button>
{stamp ? (
<span className="hidden shrink-0 text-[11px] tabular-nums text-muted-foreground sm:inline">{stamp}</span>
) : null}
</div>
);
}
export interface WorkspaceFileBrowserProps {
issueId: string;
onOpen: (ref: { path: string; workspace: WorkspaceFileSelector }) => void;
onOpen: (ref: {
path: string;
workspace: WorkspaceFileSelector;
line?: number | null;
column?: number | null;
}) => void;
/** Seed the search field (e.g. from a URL-backed deep link). */
initialQuery?: string | null;
className?: string;
}
export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFileBrowserProps) {
export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className }: WorkspaceFileBrowserProps) {
const [workspace, setWorkspace] = useState<WorkspaceFileSelector>("auto");
const [mode, setMode] = useState<Extract<WorkspaceFileListMode, "changed" | "all">>("changed");
const [searchInput, setSearchInput] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [searchInput, setSearchInput] = useState(initialQuery ?? "");
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery?.trim() ?? "");
// When the workspace has no git change-tracking we silently fall back to a full
// listing for the default (empty-query) view, per spec.
const [recentUnavailable, setRecentUnavailable] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const listboxId = useId();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedQuery(searchInput.trim()), 250);
const handle = window.setTimeout(() => setDebouncedQuery(searchInput.trim()), 150);
return () => window.clearTimeout(handle);
}, [searchInput]);
// A new search or workspace should re-attempt recent-change tracking.
useEffect(() => {
setRecentUnavailable(false);
}, [workspace]);
const q = debouncedQuery || null;
const isSearch = q !== null;
const mode: WorkspaceFileListMode = isSearch ? "all" : recentUnavailable ? "all" : "changed";
const listQuery = useQuery({
queryKey: queryKeys.issues.fileResources(issueId, {
workspace,
mode,
q,
limit: LIST_LIMIT,
}),
queryFn: () =>
fileResourcesApi.list(issueId, { workspace, mode, q, limit: LIST_LIMIT }),
queryKey: queryKeys.issues.fileResources(issueId, { workspace, mode, q, limit: LIST_LIMIT }),
queryFn: () => fileResourcesApi.list(issueId, { workspace, mode, q, limit: LIST_LIMIT }),
retry: false,
staleTime: 15_000,
});
const data = listQuery.data;
const items = data?.items ?? [];
const items = useMemo(() => data?.items ?? [], [data]);
const workspaceLabel = data?.workspace?.workspaceLabel ?? null;
const liveRegionRef = useRef<HTMLDivElement>(null);
// Silent fallback: empty-query view with no change-tracking → list everything.
useEffect(() => {
if (!isSearch && data?.state === "unavailable" && (data.unavailableReason ?? "").toLowerCase().includes("changed")) {
setRecentUnavailable(true);
}
}, [data, isSearch]);
// Keep the highlighted option valid as results change.
useEffect(() => {
setHighlightedIndex(items.length > 0 ? 0 : -1);
}, [items, q, workspace]);
const announcement = useMemo(() => {
if (listQuery.isFetching) return "Loading workspace files…";
if (listQuery.isError) return "Unable to load workspace files.";
@@ -223,6 +238,31 @@ export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFi
return `${items.length} file${items.length === 1 ? "" : "s"} found.`;
}, [data, items.length, listQuery.isError, listQuery.isFetching]);
function openTypedPath() {
const value = searchInput.trim();
if (!value) return;
const parsed = parseWorkspaceFileRef(value);
if (parsed) onOpen({ path: parsed.path, workspace, line: parsed.line, column: parsed.column });
else onOpen({ path: value, workspace });
}
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((current) => (items.length === 0 ? -1 : Math.min(items.length - 1, current + 1)));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((current) => (items.length === 0 ? -1 : Math.max(0, current - 1)));
} else if (event.key === "Enter") {
event.preventDefault();
const item = highlightedIndex >= 0 ? items[highlightedIndex] : undefined;
if (item) onOpen({ path: item.relativePath, workspace });
else openTypedPath();
}
}
const activeOptionId = highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined;
let body: ReactNode;
if (listQuery.isFetching && !data) {
body = (
@@ -237,68 +277,40 @@ export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFi
);
} else if (listQuery.isError) {
const status = listQuery.error instanceof ApiError ? listQuery.error.status : 0;
const fallback =
status === 404
? "Workspace browsing isn't available for this issue."
: "Something went wrong loading workspace files.";
body = (
<StateMessage
icon={<AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />}
title="Couldn't load files"
body={fallback}
action={
<Button type="button" variant="ghost" size="sm" onClick={() => void listQuery.refetch()}>
Retry
</Button>
body={
status === 404
? "Workspace browsing isn't available for this issue."
: "Something went wrong loading workspace files."
}
/>
);
} else if (data?.state === "unavailable") {
const detail = describeUnavailable(data.unavailableReason ?? "");
const isChangedReason = (data.unavailableReason ?? "").toLowerCase().includes("changed");
body = (
<StateMessage
icon={detail.icon}
title={detail.title}
body={detail.body}
action={
isChangedReason && mode !== "all" ? (
<Button type="button" variant="secondary" size="sm" onClick={() => setMode("all")}>
Browse all files
</Button>
) : null
}
/>
);
body = <StateMessage icon={detail.icon} title={detail.title} body={detail.body} />;
} else if (items.length === 0) {
body = (
<StateMessage
icon={<Search aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
title={debouncedQuery ? "No files match your search" : "No files to show"}
body={
debouncedQuery
? "Try a different path or name, or switch to all files."
: mode === "changed"
? "No recent changes detected. Switch to all files to browse everything."
: "This workspace has no browsable files."
}
action={
mode !== "all" ? (
<Button type="button" variant="secondary" size="sm" onClick={() => setMode("all")}>
Browse all files
</Button>
) : null
}
title={isSearch ? `No files match “${q}”` : "No recently changed files yet"}
body="Try searching by name or path."
/>
);
} else {
body = (
<div className="space-y-0.5 py-1">
{items.map((item) => (
<div role="listbox" id={listboxId} aria-label="Workspace files" className="space-y-0.5 py-1">
{items.map((item, index) => (
<WorkspaceFileRow
key={`${item.workspaceId}:${item.relativePath}`}
item={item}
onOpen={(picked) => onOpen({ path: picked.relativePath, workspace })}
optionId={`${listboxId}-opt-${index}`}
selected={index === highlightedIndex}
showTimestamp={!isSearch}
onOpen={() => onOpen({ path: item.relativePath, workspace })}
onHover={() => setHighlightedIndex(index)}
/>
))}
</div>
@@ -307,53 +319,62 @@ export function WorkspaceFileBrowser({ issueId, onOpen, className }: WorkspaceFi
return (
<div className={cn("flex min-h-0 flex-col gap-2", className)}>
<div className="flex items-center gap-2">
<FolderSearch aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="relative flex-1">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Search workspace files by path or name"
aria-label="Search workspace files"
className="h-8 pl-8 font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={inputRef}
type="search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search files by name or path…"
aria-label="Search workspace files"
role="combobox"
aria-expanded={items.length > 0}
aria-controls={items.length > 0 ? listboxId : undefined}
aria-activedescendant={activeOptionId}
autoFocus
autoComplete="off"
spellCheck={false}
className="h-8 pl-8 font-mono text-xs"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<SegmentedControl label="Workspace" value={workspace} options={WORKSPACE_OPTIONS} onChange={setWorkspace} />
<SegmentedControl label="File set" value={mode} options={MODE_OPTIONS} onChange={setMode} />
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Workspace</span>
<WorkspaceSelector value={workspace} onChange={setWorkspace} />
</div>
<div className="flex items-baseline justify-between gap-2 pt-1">
<span className="text-xs font-medium text-muted-foreground">
{isSearch ? <>Files matching “{q}”</> : "Recently changed"}
</span>
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{listQuery.isFetching ? <Loader2 aria-hidden="true" className="h-3 w-3 animate-spin" /> : null}
{data?.state === "available" && items.length > 0 ? <span>· {items.length}</span> : null}
</span>
</div>
{workspaceLabel ? (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="truncate" title={workspaceLabel}>
From {workspaceLabel}
</span>
{listQuery.isFetching ? (
<Loader2 aria-hidden="true" className="h-3 w-3 shrink-0 animate-spin" />
) : null}
{data?.truncated ? (
<>
<span aria-hidden="true" className="opacity-50">·</span>
<span>Showing first {items.length}</span>
</>
) : null}
<div className="truncate text-[11px] text-muted-foreground" title={workspaceLabel}>
From {workspaceLabel}
</div>
) : null}
<div ref={liveRegionRef} aria-live="polite" className="sr-only">
<div aria-live="polite" className="sr-only">
{announcement}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
{data?.truncated ? (
<div className="border-t border-border pt-2 text-[11px] text-muted-foreground">
Showing first {items.length} — refine the search to narrow.
</div>
) : null}
</div>
);
}
+90 -18
View File
@@ -14,7 +14,18 @@ export interface FileViewerContextValue {
issueId: string;
/** Current viewer state derived from the URL, or null if closed. */
state: FileViewerUrlState | null;
open(ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column"> & { workspace?: WorkspaceFileSelector }): void;
/** True when the sheet is in browse mode (URL carries `browse=1`). */
browse: boolean;
/** The active browse search query (URL `q`), or null. */
query: string | null;
open(
ref: Pick<ParsedWorkspaceFileRef, "path" | "line" | "column"> & { workspace?: WorkspaceFileSelector },
opts?: { fromBrowse?: boolean },
): void;
/** Open (or stay in) browse mode, optionally seeding the search query. */
openBrowse(opts?: { q?: string }): void;
/** From a file opened via browse, return to the browse list. */
backToFiles(): void;
close(): void;
}
@@ -42,6 +53,9 @@ export function readFileViewerStateFromSearch(search: string): FileViewerUrlStat
export function writeFileViewerStateToSearch(current: string, next: FileViewerUrlState | null): string {
const params = new URLSearchParams(current);
// A direct file open/close is never a browse origin — clear browse params too.
params.delete("browse");
params.delete("q");
if (!next) {
params.delete("file");
params.delete("line");
@@ -60,6 +74,17 @@ export function writeFileViewerStateToSearch(current: string, next: FileViewerUr
return str ? `?${str}` : "";
}
export interface FileViewerBrowseState {
q: string | null;
}
export function readBrowseStateFromSearch(search: string): FileViewerBrowseState | null {
const params = new URLSearchParams(search);
if (params.get("browse") !== "1") return null;
const q = params.get("q");
return { q: q && q.length > 0 ? q : null };
}
interface FileViewerProviderProps {
issueId: string;
children: ReactNode;
@@ -69,34 +94,81 @@ export function FileViewerProvider({ issueId, children }: FileViewerProviderProp
const location = useLocation();
const navigate = useNavigate();
const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]);
const browseState = useMemo(() => readBrowseStateFromSearch(location.search), [location.search]);
const open = useCallback<FileViewerContextValue["open"]>(
(ref) => {
const nextSearch = writeFileViewerStateToSearch(location.search, {
path: ref.path,
line: ref.line ?? null,
column: ref.column ?? null,
workspace: ref.workspace ?? "auto",
});
const navigateSearch = useCallback(
(nextSearch: string) => {
navigate(
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
{ state: location.state, replace: false },
);
},
[location.hash, location.pathname, location.search, location.state, navigate],
[location.hash, location.pathname, location.state, navigate],
);
const open = useCallback<FileViewerContextValue["open"]>(
(ref, opts) => {
let nextSearch = writeFileViewerStateToSearch(location.search, {
path: ref.path,
line: ref.line ?? null,
column: ref.column ?? null,
workspace: ref.workspace ?? "auto",
});
if (opts?.fromBrowse) {
const params = new URLSearchParams(nextSearch);
params.set("browse", "1");
const prevQ = new URLSearchParams(location.search).get("q");
if (prevQ) params.set("q", prevQ);
nextSearch = params.toString() ? `?${params.toString()}` : "";
}
navigateSearch(nextSearch);
},
[location.search, navigateSearch],
);
const openBrowse = useCallback<FileViewerContextValue["openBrowse"]>(
(opts) => {
const params = new URLSearchParams(location.search);
params.delete("file");
params.delete("line");
params.delete("column");
params.set("browse", "1");
if (typeof opts?.q === "string" && opts.q.length > 0) params.set("q", opts.q);
else params.delete("q");
navigateSearch(params.toString() ? `?${params.toString()}` : "");
},
[location.search, navigateSearch],
);
const backToFiles = useCallback(() => {
const params = new URLSearchParams(location.search);
params.delete("file");
params.delete("line");
params.delete("column");
params.delete("workspace");
params.set("browse", "1");
navigateSearch(params.toString() ? `?${params.toString()}` : "");
}, [location.search, navigateSearch]);
const close = useCallback(() => {
const nextSearch = writeFileViewerStateToSearch(location.search, null);
navigate(
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
{ state: location.state, replace: false },
);
}, [location.hash, location.pathname, location.search, location.state, navigate]);
const params = new URLSearchParams(writeFileViewerStateToSearch(location.search, null).replace(/^\?/, ""));
params.delete("browse");
params.delete("q");
navigateSearch(params.toString() ? `?${params.toString()}` : "");
}, [location.search, navigateSearch]);
const value = useMemo<FileViewerContextValue>(
() => ({ issueId, state, open, close }),
[issueId, state, open, close],
() => ({
issueId,
state,
browse: browseState !== null,
query: browseState?.q ?? null,
open,
openBrowse,
backToFiles,
close,
}),
[issueId, state, browseState, open, openBrowse, backToFiles, close],
);
return <FileViewerContext.Provider value={value}>{children}</FileViewerContext.Provider>;
+6 -17
View File
@@ -136,7 +136,6 @@ import {
EyeOff,
Flag,
FileCode2,
FolderSearch,
Hexagon,
ListTree,
MessageSquare,
@@ -4041,6 +4040,8 @@ export function IssueDetail() {
issue={issue}
project={resolvedProject}
onUpdate={(data) => updateIssue.mutate(data)}
onBrowseFiles={() => setFileViewerPromptOpen(true)}
onOpenFileByPath={() => setFileViewerPromptOpen(true)}
/>
{issue.workProducts && issue.workProducts.length > 0 && (() => {
@@ -4068,19 +4069,6 @@ export function IssueDetail() {
);
})()}
<div className="flex justify-start">
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
onClick={() => setFileViewerPromptOpen(true)}
title="Browse and search files in this issue's workspace"
>
<FolderSearch className="h-3.5 w-3.5" />
Browse workspace
</Button>
</div>
<Separator />
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
@@ -4418,8 +4406,8 @@ function IssueFileViewer({
onPromptOpenChange: (next: boolean) => void;
}) {
const viewer = useRequiredFileViewer();
const open = viewer.state !== null || promptOpen;
const showPromptWhenEmpty = promptOpen && viewer.state === null;
const open = viewer.state !== null || viewer.browse || promptOpen;
const showPromptWhenEmpty = (promptOpen || viewer.browse) && viewer.state === null;
return (
<FileViewerSheet
issueId={issueId}
@@ -4428,7 +4416,8 @@ function IssueFileViewer({
onOpenChange={(next) => {
if (!next) {
onPromptOpenChange(false);
if (viewer.state !== null) viewer.close();
// Clears any file view and browse state from the URL.
viewer.close();
}
}}
/>
@@ -1,66 +1,66 @@
import { useMemo, type ReactNode } from "react";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared";
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import type {
ResolvedWorkspaceResource,
WorkspaceFileContent,
WorkspaceFileListItem,
WorkspaceFileListResponse,
} from "@paperclipai/shared";
import { FileViewerProvider, useRequiredFileViewer } from "@/context/FileViewerContext";
import { FileViewerSheet } from "@/components/FileViewerSheet";
import { IssueWorkspaceCard } from "@/components/IssueWorkspaceCard";
import { queryKeys } from "@/lib/queryKeys";
/**
* Screenshot review surface for PAP-10511 — the issue-page "Browse workspace"
* fallback. Each story seeds the react-query cache for the default browse query
* (workspace=auto, mode=changed) so the component renders without a backend.
* Screenshot-review surface for PAP-10511, rendering the REAL FileViewerSheet /
* IssueWorkspaceCard (not a static frame) so the captures are visual truth.
* Stories seed the react-query cache so the components render without a backend.
* Capture each at desktop + 360px and in both themes via the Storybook theme global.
*/
const ISSUE_ID = "issue-browse-demo";
function seedKey(issueId: string) {
return queryKeys.issues.fileResources(issueId, {
workspace: "auto",
mode: "changed",
q: null,
limit: 100,
});
function listKey(issueId: string) {
return queryKeys.issues.fileResources(issueId, { workspace: "auto", mode: "changed", q: null, limit: 100 });
}
function item(relativePath: string, overrides: Partial<WorkspaceFileListItem> = {}): WorkspaceFileListItem {
function item(relativePath: string, minutesAgo: number, overrides: Partial<WorkspaceFileListItem> = {}): WorkspaceFileListItem {
return {
kind: "file",
provider: "git_worktree",
title: relativePath.split("/").pop() ?? relativePath,
relativePath,
displayPath: relativePath,
workspaceLabel: "Isolated workspace",
workspaceLabel: "issue execution workspace · PAP-1953",
workspaceKind: "execution_workspace",
workspaceId: "ws-1",
contentType: "text/plain; charset=utf-8",
byteSize: 2048,
modifiedAt: null,
modifiedAt: new Date(Date.now() - minutesAgo * 60_000).toISOString(),
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
...overrides,
};
}
const availableData: WorkspaceFileListResponse = {
const recentList: WorkspaceFileListResponse = {
kind: "workspace_file_list",
state: "available",
workspace: {
provider: "git_worktree",
workspaceLabel: "Isolated workspace",
workspaceLabel: "issue execution workspace · PAP-1953",
workspaceKind: "execution_workspace",
workspaceId: "ws-1",
},
query: { workspace: "auto", mode: "changed", q: null, limit: 100 },
items: [
item("ui/src/components/WorkspaceFileBrowser.tsx"),
item("ui/src/components/FileViewerSheet.tsx", { byteSize: 28_400 }),
item("server/src/routes/file-resources.ts", { byteSize: 8_120 }),
item("packages/shared/src/types/workspace-file-resource.ts", { byteSize: 3_200 }),
item("docs/screenshots/preview.png", { previewKind: "image", contentType: "image/png", byteSize: 102_400 }),
item(
"server/src/services/very/deeply/nested/directory/structure/that/keeps/going/workspace-file-resources.ts",
{ byteSize: 16_900 },
),
item("ui/src/components/WorkspaceFileBrowser.tsx", 2),
item("ui/src/components/FileViewerSheet.tsx", 2),
item("server/src/routes/file-resources.ts", 14),
item("doc/PRODUCT.md", 60),
item("packages/shared/src/types/workspace-file-resource.ts", 64),
item("server/src/services/very/deeply/nested/structure/workspace-file-resources.ts", 180),
],
scannedCount: 412,
truncated: true,
@@ -79,45 +79,128 @@ function unavailable(reason: string): WorkspaceFileListResponse {
};
}
function SheetFrame({ children }: { children: ReactNode }) {
return (
<div className="flex h-[560px] w-[420px] flex-col overflow-hidden rounded-lg border border-border bg-background">
<div className="border-b border-border p-3">
<p className="text-sm font-medium text-foreground">Browse workspace</p>
<p className="text-xs text-muted-foreground">Search and preview files from this issue's workspace.</p>
</div>
<div className="flex min-h-0 flex-1 flex-col p-4">{children}</div>
</div>
);
}
const viewedResource: ResolvedWorkspaceResource = {
kind: "file",
provider: "git_worktree",
title: "FileViewerSheet.tsx",
displayPath: "ui/src/components/FileViewerSheet.tsx",
workspaceLabel: "issue execution workspace · PAP-1953",
workspaceKind: "execution_workspace",
workspaceId: "ws-1",
contentType: "text/plain; charset=utf-8",
byteSize: 2048,
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
};
function Demo({ data }: { data: WorkspaceFileListResponse }) {
const viewedContent: WorkspaceFileContent = {
resource: viewedResource,
content: {
encoding: "utf8",
data: [
"export function FileViewerSheet({ issueId, state, open }: FileViewerSheetProps) {",
" const viewer = useRequiredFileViewer();",
" const browseMode = state === null && (showPromptWhenEmpty || viewer.browse);",
" const cameFromBrowse = state !== null && viewer.browse;",
" // … single sheet, two modes: browse ⇄ view",
" return <Sheet open={open}>…</Sheet>;",
"}",
].join("\n"),
},
};
function BrowseSheet({ data }: { data: WorkspaceFileListResponse }) {
const queryClient = useQueryClient();
useMemo(() => {
queryClient.setQueryData(seedKey(ISSUE_ID), data);
}, [queryClient, data]);
queryClient.setQueryData(listKey(ISSUE_ID), data);
return (
<SheetFrame>
<WorkspaceFileBrowser
issueId={ISSUE_ID}
onOpen={(ref) => console.log("open", ref)}
className="min-h-0 flex-1"
/>
</SheetFrame>
<FileViewerProvider issueId={ISSUE_ID}>
<FileViewerSheet issueId={ISSUE_ID} showPromptWhenEmpty />
</FileViewerProvider>
);
}
const meta: Meta<typeof Demo> = {
function ViewFromBrowseInner() {
const viewer = useRequiredFileViewer();
useEffect(() => {
viewer.open(
{ path: "ui/src/components/FileViewerSheet.tsx", line: 3, column: null, workspace: "auto" },
{ fromBrowse: true },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <FileViewerSheet issueId={ISSUE_ID} />;
}
function ViewFromBrowse() {
const queryClient = useQueryClient();
queryClient.setQueryData(listKey(ISSUE_ID), recentList);
queryClient.setQueryData(
queryKeys.issues.fileResource(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
viewedResource,
);
queryClient.setQueryData(
queryKeys.issues.fileResourceContent(ISSUE_ID, "ui/src/components/FileViewerSheet.tsx", "auto"),
viewedContent,
);
return (
<FileViewerProvider issueId={ISSUE_ID}>
<ViewFromBrowseInner />
</FileViewerProvider>
);
}
function Placement() {
const queryClient = useQueryClient();
queryClient.setQueryData(queryKeys.instance.experimentalSettings, {
enableEnvironments: false,
enableIsolatedWorkspaces: true,
});
return (
<FileViewerProvider issueId={ISSUE_ID}>
<div className="mx-auto max-w-3xl space-y-3 p-6">
<h3 className="text-sm font-medium text-muted-foreground">Workspace</h3>
<IssueWorkspaceCard
issue={{
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "pw-1",
executionWorkspaceId: "ws-1",
executionWorkspacePreference: "isolated_workspace",
executionWorkspaceSettings: { mode: "isolated_workspace", environmentId: null },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
currentExecutionWorkspace: {
id: "ws-1",
mode: "isolated_workspace",
status: "active",
branchName: "PAP-1953-plan-a-file-viewer",
cwd: "/srv/paperclip/.../worktrees/PAP-1953",
repoUrl: null,
projectWorkspaceId: "pw-1",
name: "PAP-1953",
config: {},
} as any,
}}
project={{ id: "project-1", executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" } }}
onUpdate={() => {}}
onBrowseFiles={() => {}}
onOpenFileByPath={() => {}}
/>
</div>
</FileViewerProvider>
);
}
const meta: Meta = {
title: "Issue/Workspace File Browser",
component: Demo,
parameters: { layout: "centered" },
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof Demo>;
type Story = StoryObj;
export const RecentChanges: Story = { args: { data: availableData } };
export const NoWorkspace: Story = { args: { data: unavailable("no_workspace") } };
export const RemoteWorkspace: Story = { args: { data: unavailable("remote_workspace") } };
export const CleanedUpWorkspace: Story = { args: { data: unavailable("workspace_unavailable") } };
export const RecentChangesUnavailable: Story = { args: { data: unavailable("changed_unavailable") } };
export const BrowseRecent: Story = { render: () => <BrowseSheet data={recentList} /> };
export const BrowseNoWorkspace: Story = { render: () => <BrowseSheet data={unavailable("no_workspace")} /> };
export const BrowseRemoteWorkspace: Story = { render: () => <BrowseSheet data={unavailable("remote_workspace")} /> };
export const BrowseCleanedUpWorkspace: Story = { render: () => <BrowseSheet data={unavailable("workspace_unavailable")} /> };
export const ViewModeFromBrowse: Story = { render: () => <ViewFromBrowse /> };
export const CardPlacement: Story = { render: () => <Placement /> };