Files
PaperClipAI/ui/src/components/WorkspaceFileBrowser.tsx
T
39279aa34f feat(ui): shared error, connectivity and query foundation for disconnects (#15754)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board reads API data and keeps the current task on screen.
> - A restart or temporary network failure can interrupt those reads.
> - Separate page error handlers can hide cached data or expose raw
server codes.
> - The app needs one connection state, readable error copy and a
consistent read policy.
> - This change provides that foundation and moves affected read
surfaces onto it.
> - The dependent write layer in #17960 preserves edits and submissions
across the same failures.

## Linked Issues or Issue Description

**What happened?**

Temporary API failures could replace loaded content with errors.
Different surfaces handled the same outage differently. Some showed raw
server codes. A successful query to an unrelated service could also
falsely report that Paperclip had recovered.

**Expected behavior**

Keep cached content visible. Show one connection banner during sustained
trouble. Confirm recovery through the Paperclip health probe or its
WebSocket. Retry transient reads and show readable errors for permanent
failures.

**Steps to reproduce**

1. Open a populated task or workspace page.
2. Interrupt API requests with a 503 or a dropped connection.
3. Keep an unrelated external request in flight.
4. Confirm that cached content remains and that the external result
cannot clear the outage.
5. Restore the API and check that reads refresh.

The write layer first merged into this feature branch in #15769 now has
its own dependent PR, #17960. This PR contains the shared foundation and
read changes. Merge this PR first.

## What Changed

- Classify API errors and provide readable error copy, including
non-JSON proxy responses and Retry-After handling.
- Use one connection store and health probe with bounded backoff.
Confirm recovery from the server health probe or live-updates WebSocket.
- Pause reads while disconnected, retry transient errors, and refresh
active reads after recovery.
- Keep cached data visible through shared query views and affected page,
panel and plugin read surfaces.
- Add one app-level connection banner and support the pending-write
count consumed by the dependent write layer.
- Add a query-error rendering guard. Call it directly from the existing
CI step. Update development documentation.

## Verification

- Historical-chat lookup now follows the same ready/stale roster state
as rendering. Three regressions fail before the fix; the six new cases
pass after it, including working Retry, fresh data, no-cache error,
permanent 403 and company isolation. All six focused query/recovery
suites pass 200 tests. Final-commit CI passes and Apex is 5/5 with no
unresolved findings. Canary Dry Run initially refused a preview version
that was already on npm. It passed after one rerun with fresh registry
metadata; no check was disabled.

- Integrated current master and retained both transcript interest
registration and connectivity reporting on socket open. The seven
focused recovery/query suites pass 288 tests, and UI typecheck and
production build pass. All 9,451 UI tests across 799 files pass on the
merge commit. Final-commit CI passes and Apex is 5/5 with no unresolved
findings.

- Current head `70d9335d732219352ef0f98414439766bbcb33df`: all hosted CI
checks pass. Apex is **5/5** on that head with no unresolved findings.
The branch has no merge conflict. GitHub still requires code-owner
approval.

- The foundation passes 9,430 UI tests in 804 files. After integrating
current master page paths, 190 focused page tests pass.
- Two unrelated-success regressions fail before the latest fix. With the
fix, 62 focused UI tests, all six guard tests and UI TypeScript pass.
- The composed foundation and write layer pass **9,505 UI tests in 808
files**, UI TypeScript, a production UI build, and query-error, token
and map guards.
- A real Chromium journey on the composed source injects a 503 during
title save. The loaded task stays visible; one reconnect banner appears;
the edit saves after recovery; another task stays unchanged; the saved
title survives reload.
- Full recursive local checks were not repeated because Runner
compilation exhausted local disk during another repair. Hosted checks
must pass before merge. No new staging deployment is claimed.

- Latest review follow-up: a cached CaseDetail now survives a failed
HTTP 429 refresh. Its page regression fails before the fix and passes
after it. Eighty focused foundation tests, UI TypeScript, build and
guards pass after that change.
- Earlier CI evidence is retained: several foundation jobs received a
runner shutdown signal together. The write-layer head also failed an
unchanged Runner descriptor close test with `provider_transport_failed`.
The same Runner source passed that suite in the foundation run. New CI
runs cover the updated heads; no check or assertion was weakened.

## Risks

- Shared retry defaults change the time before transient read errors
appear. Permanent errors still fail promptly.
- ApiError.message now contains readable copy. Callers that compare raw
codes must use ApiError.code.
- Cache success can come from another service or local data. It must not
confirm Paperclip recovery.
- Durable writes are reviewed separately in #17960. This PR does not
change database or mutation contracts.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original work
with tool use. OpenAI Codex, GPT-6 family, assisted with restoration,
integration, tests and review fixes. The Codex session does not expose
its exact serving model ID or context window size.

## 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: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Amy (Opus) <claude@users.noreply.github.com>
2026-10-27 21:22:07 -07:00

1163 lines
42 KiB
TypeScript

import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ChevronDown, ChevronRight, Cloud, Download, FileCode2, FolderOpen, Loader2, RefreshCcw, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { fileResourcesApi } from "@/api/file-resources";
import { projectsApi } from "@/api/projects";
import { ApiError } from "@/api/client";
import { describeError } from "@/api/errors";
import { queryViewKind } from "@/components/QueryView";
import { queryKeys } from "@/lib/queryKeys";
import { usePageVisibility } from "@/lib/page-visibility";
import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser";
import type {
Project,
WorkspaceFileListItem,
WorkspaceFileListFileItem,
WorkspaceFileListMode,
WorkspaceFileSelector,
} from "@paperclipai/shared";
type BrowserSource = "current" | "other";
// 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 basename(path: string): string {
const idx = path.lastIndexOf("/");
return idx < 0 ? path : path.slice(idx + 1);
}
function normalizeFolderPrefix(path: string | null | undefined): string {
if (!path) return "";
const trimmed = path.replace(/^\/+/, "").replace(/\/+$/, "");
return trimmed ? `${trimmed}/` : "";
}
function parentFolderPath(path: string | null | undefined): string | null {
const trimmed = path?.replace(/^\/+/, "").replace(/\/+$/, "");
if (!trimmed) return null;
const index = trimmed.lastIndexOf("/");
return index > 0 ? trimmed.slice(0, index) : null;
}
function folderKey(path: string | null | undefined): string {
return path?.replace(/^\/+/, "").replace(/\/+$/, "") ?? "";
}
function selectedAncestorFolders(selectedPath: string | null | undefined, rootPath: string | null | undefined): string[] {
const parent = parentFolderPath(selectedPath);
if (!parent) return [];
const root = folderKey(rootPath);
const parentSegments = parent.split("/").filter(Boolean);
const rootSegments = root ? root.split("/").filter(Boolean) : [];
if (rootSegments.some((segment, index) => parentSegments[index] !== segment)) return [];
const paths: string[] = [];
for (let index = rootSegments.length; index < parentSegments.length; index += 1) {
paths.push(parentSegments.slice(0, index + 1).join("/"));
}
return paths;
}
/**
* 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 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")) {
return {
icon: <FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />,
title: "No workspace yet",
body: "This issue does not have a workspace to browse. Files appear here once a run creates one.",
};
}
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 is no longer available",
body: "The isolated worktree for this issue has been cleaned up, so files cannot be previewed.",
};
}
return {
icon: <AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />,
title: "Workspace unavailable",
body: "These workspace files can't be browsed right now.",
};
}
function StateMessage({
icon,
title,
body,
actions,
}: {
icon: ReactNode;
title: string;
body?: string;
actions?: ReactNode;
}) {
return (
<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}
{actions ? <div className="pt-1">{actions}</div> : null}
</div>
</div>
);
}
function WorkspaceFileBreadcrumbs({
rootLabel,
folderPath,
onOpenFolder,
}: {
rootLabel: string | null;
folderPath: string | null;
onOpenFolder: (path: string | null) => void;
}) {
const segments = folderPath?.split("/").filter(Boolean) ?? [];
if (!rootLabel && segments.length === 0) return null;
return (
<nav aria-label="Current folder" className="min-w-0 overflow-hidden text-(length:--text-micro) text-muted-foreground">
<ol className="flex min-w-0 items-center gap-1 overflow-hidden">
{rootLabel ? (
<li className="min-w-0 shrink">
<button
type="button"
onClick={() => onOpenFolder(null)}
className="max-w-full truncate rounded px-1 py-0.5 text-left hover:bg-accent hover:text-foreground"
title={rootLabel}
>
{rootLabel}
</button>
</li>
) : null}
{segments.map((segment, index) => {
const path = segments.slice(0, index + 1).join("/");
return (
<li key={path} className="flex min-w-0 shrink items-center gap-1">
<span aria-hidden="true" className="shrink-0 opacity-50">/</span>
<button
type="button"
onClick={() => onOpenFolder(path)}
className="max-w-full truncate rounded px-1 py-0.5 text-left font-mono hover:bg-accent hover:text-foreground"
title={path}
>
{segment}
</button>
</li>
);
})}
</ol>
</nav>
);
}
interface WorkspaceFileRowProps {
item: WorkspaceFileListFileItem;
treeItemId: string;
selected: boolean;
highlighted: boolean;
depth: number;
onOpen: () => void;
onHover: () => void;
downloadUrl: string | null;
}
function WorkspaceFileRow({ item, treeItemId, selected, highlighted, depth, onOpen, onHover, downloadUrl }: WorkspaceFileRowProps) {
const name = basename(item.relativePath);
return (
<div
id={treeItemId}
role="treeitem"
aria-selected={selected}
onClick={onOpen}
onMouseEnter={onHover}
title={item.displayPath}
className={cn(
"flex min-h-(--sz-36px) cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 sm:min-h-0",
selected ? "bg-accent text-foreground" : highlighted ? "bg-accent/50" : "hover:bg-accent/60",
)}
style={{ paddingLeft: `${0.5 + depth * 0.875}rem` }}
>
<FileCode2 aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{name}</span>
{downloadUrl ? (
<a
href={downloadUrl}
download={name}
aria-label={`Download ${name}`}
title={`Download ${name}`}
onClick={(event) => event.stopPropagation()}
className="ml-auto inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 hover:bg-background/70 hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Download aria-hidden="true" className="h-3.5 w-3.5" />
</a>
) : null}
</div>
);
}
interface WorkspaceFileTreeFolderNode {
kind: "folder";
key: string;
name: string;
depth: number;
children: WorkspaceFileTreeNode[];
lazy: boolean;
}
interface WorkspaceFileTreeFileNode {
kind: "file";
key: string;
item: WorkspaceFileListFileItem;
depth: number;
}
type WorkspaceFileTreeNode = WorkspaceFileTreeFolderNode | WorkspaceFileTreeFileNode;
interface MutableTreeFolder {
kind: "folder";
key: string;
name: string;
depth: number;
folders: Map<string, MutableTreeFolder>;
files: WorkspaceFileTreeFileNode[];
}
function itemKey(item: Pick<WorkspaceFileListItem, "kind" | "workspaceId" | "relativePath">): string {
return `${item.kind}:${item.workspaceId}:${item.relativePath}`;
}
function compareTreeNodes(a: WorkspaceFileTreeNode, b: WorkspaceFileTreeNode): number {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
const aName = a.kind === "folder" ? a.name : basename(a.item.relativePath);
const bName = b.kind === "folder" ? b.name : basename(b.item.relativePath);
return aName.localeCompare(bName);
}
function finalizeTreeFolder(folder: MutableTreeFolder): WorkspaceFileTreeFolderNode {
const children: WorkspaceFileTreeNode[] = [
...Array.from(folder.folders.values()).map(finalizeTreeFolder),
...folder.files,
];
children.sort(compareTreeNodes);
return {
kind: "folder",
key: folder.key,
name: folder.name,
depth: folder.depth,
children,
lazy: false,
};
}
function buildWorkspaceFileTree(items: WorkspaceFileListItem[], rootPath: string | null | undefined) {
const rootPrefix = normalizeFolderPrefix(rootPath);
const root: MutableTreeFolder = {
kind: "folder",
key: "__root__",
name: "",
depth: -1,
folders: new Map(),
files: [],
};
for (const item of items) {
if (item.kind !== "file") continue;
const path = item.relativePath.startsWith(rootPrefix)
? item.relativePath.slice(rootPrefix.length)
: item.relativePath;
const segments = path.split("/").filter(Boolean);
const fileName = segments.pop() ?? basename(item.relativePath);
let cursor = root;
let currentPath = rootPrefix.replace(/\/$/, "");
for (const segment of segments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
let folder = cursor.folders.get(segment);
if (!folder) {
folder = {
kind: "folder",
key: currentPath,
name: segment,
depth: cursor.depth + 1,
folders: new Map(),
files: [],
};
cursor.folders.set(segment, folder);
}
cursor = folder;
}
cursor.files.push({
kind: "file",
key: itemKey(item),
item: { ...item, title: fileName },
depth: cursor.depth + 1,
});
}
const tree = finalizeTreeFolder(root);
return tree.children;
}
function buildWorkspaceDirectoryTree(items: WorkspaceFileListItem[]) {
const nodes = items.map((item): WorkspaceFileTreeNode => {
if (item.kind === "directory") {
return {
kind: "folder",
key: item.relativePath,
name: basename(item.relativePath),
depth: 0,
children: [],
lazy: true,
};
}
return {
kind: "file",
key: itemKey(item),
item,
depth: 0,
};
});
nodes.sort(compareTreeNodes);
return nodes;
}
interface WorkspaceFileTreeProps {
nodes: WorkspaceFileTreeNode[];
listboxId: string;
highlightedItemKey: string | null;
selectedItemKey: string | null;
collapsedFolders: Set<string>;
expandedLazyFolders: Set<string>;
forcedExpandedFolders: Set<string>;
getLazyChildren?: (path: string, depth: number) => WorkspaceFileTreeNode[];
isLazyFolderFetching?: (path: string) => boolean;
isLazyFolderTruncated?: (path: string) => boolean;
onLoadMoreFolder?: (path: string) => void;
onToggleFolder: (key: string) => void;
onOpen: (item: WorkspaceFileListFileItem) => void;
onHoverFile: (item: WorkspaceFileListFileItem) => void;
getDownloadUrl: (item: WorkspaceFileListFileItem) => string | null;
}
function WorkspaceFileTree({
nodes,
listboxId,
highlightedItemKey,
selectedItemKey,
collapsedFolders,
expandedLazyFolders,
forcedExpandedFolders,
getLazyChildren,
isLazyFolderFetching,
isLazyFolderTruncated,
onLoadMoreFolder,
onToggleFolder,
onOpen,
onHoverFile,
getDownloadUrl,
}: WorkspaceFileTreeProps) {
function renderNode(node: WorkspaceFileTreeNode): ReactNode {
if (node.kind === "folder") {
const expanded = node.lazy
? forcedExpandedFolders.has(node.key) || expandedLazyFolders.has(node.key)
: forcedExpandedFolders.has(node.key) || !collapsedFolders.has(node.key);
const children = node.lazy ? getLazyChildren?.(node.key, node.depth + 1) ?? [] : node.children;
const loading = node.lazy && isLazyFolderFetching?.(node.key);
const truncated = node.lazy && isLazyFolderTruncated?.(node.key);
return (
<div key={node.key}>
<button
type="button"
role="treeitem"
aria-expanded={expanded}
title={node.key}
onClick={() => onToggleFolder(node.key)}
className="flex min-h-(--sz-32px) w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-accent/60"
style={{ paddingLeft: `${0.25 + node.depth * 0.875}rem` }}
>
{expanded ? (
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{node.name}</span>
</button>
{expanded ? (
<>
{children.map(renderNode)}
{loading ? (
<div
className="flex min-h-(--sz-30px) items-center gap-2 py-1 pr-2 text-xs text-muted-foreground"
style={{ paddingLeft: `${1 + (node.depth + 1) * 0.875}rem` }}
>
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
<span>Loading folder…</span>
</div>
) : null}
{truncated ? (
<button
type="button"
onClick={() => onLoadMoreFolder?.(node.key)}
className="flex min-h-(--sz-30px) w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs text-muted-foreground hover:bg-accent/60 hover:text-foreground"
style={{ paddingLeft: `${1 + (node.depth + 1) * 0.875}rem` }}
>
<span className="h-3.5 w-3.5 shrink-0" />
<span>Load more from this folder</span>
</button>
) : null}
</>
) : null}
</div>
);
}
return (
<WorkspaceFileRow
key={node.key}
item={node.item}
treeItemId={`${listboxId}-file-${node.key}`}
selected={node.key === selectedItemKey}
highlighted={node.key === highlightedItemKey}
depth={node.depth}
onOpen={() => onOpen(node.item)}
onHover={() => onHoverFile(node.item)}
downloadUrl={getDownloadUrl(node.item)}
/>
);
}
return (
<div role="tree" id={listboxId} aria-label="Workspace files" className="space-y-0.5 py-1">
{nodes.map(renderNode)}
</div>
);
}
export interface WorkspaceFileBrowserProps {
issueId: string;
companyId?: string | null;
onOpen: (ref: {
path: string;
workspace: WorkspaceFileSelector;
line?: number | null;
column?: number | null;
projectId?: string | null;
workspaceId?: string | null;
browseFolderPath?: string | null;
browseQuery?: string | null;
}) => void;
onBrowseStateChange?: (state: {
q: string | null;
folderPath: string | null;
projectId: string | null;
workspaceId: string | null;
}) => void;
/** Seed the search field (e.g. from a URL-backed deep link). */
initialQuery?: string | null;
initialFolderPath?: string | null;
initialProjectId?: string | null;
initialWorkspaceId?: string | null;
autoFocusSearch?: boolean;
compact?: boolean;
selectedPath?: string | null;
selectedProjectId?: string | null;
selectedWorkspaceId?: string | null;
/** True only while the containing Files panel is actually open. */
active?: boolean;
className?: string;
}
export function WorkspaceFileBrowser({
issueId,
companyId,
onOpen,
onBrowseStateChange,
initialQuery,
initialFolderPath,
initialProjectId,
initialWorkspaceId,
autoFocusSearch = true,
selectedPath,
selectedProjectId: activeProjectId,
selectedWorkspaceId: activeWorkspaceId,
active = true,
className,
}: WorkspaceFileBrowserProps) {
const queryClient = useQueryClient();
const { visible: pageVisible } = usePageVisibility();
const source: BrowserSource =
initialProjectId && initialWorkspaceId ? "other" : "current";
const workspace: WorkspaceFileSelector = "auto";
const selectedParentPath = useMemo(() => parentFolderPath(selectedPath), [selectedPath]);
const effectiveInitialFolderPath = useMemo(() => {
if (typeof initialFolderPath !== "undefined") return initialFolderPath;
return initialQuery?.trim() ? null : selectedParentPath;
}, [initialFolderPath, initialQuery, selectedParentPath]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(initialProjectId ?? null);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(initialWorkspaceId ?? null);
const [folderPath, setFolderPath] = useState<string | null>(effectiveInitialFolderPath ?? null);
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 [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set());
const [expandedLazyFolders, setExpandedLazyFolders] = useState<Set<string>>(() => new Set());
const [folderPageCounts, setFolderPageCounts] = useState<Record<string, number>>({});
const listboxId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const projectsQuery = useQuery({
queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__none__"],
queryFn: () => projectsApi.list(companyId!),
enabled: source === "other" && !!companyId,
staleTime: 30_000,
});
const projectRows = useMemo(
() => Array.isArray(projectsQuery.data) ? projectsQuery.data : [],
[projectsQuery.data],
);
const projectsWithWorkspaces = useMemo(
() => projectRows.filter((project: Project) => project.workspaces.length > 0),
[projectRows],
);
const selectedProject = useMemo(
() => projectsWithWorkspaces.find((project) => project.id === selectedProjectId) ?? null,
[projectsWithWorkspaces, selectedProjectId],
);
const selectedWorkspace = useMemo(
() => selectedProject?.workspaces.find((item) => item.id === selectedWorkspaceId) ?? null,
[selectedProject, selectedWorkspaceId],
);
useEffect(() => {
if (source !== "other" || !projectsQuery.data) return;
const nextProject = selectedProject ?? projectsWithWorkspaces[0] ?? null;
if (!nextProject) {
setSelectedProjectId(null);
setSelectedWorkspaceId(null);
return;
}
if (selectedProjectId !== nextProject.id) {
setSelectedProjectId(nextProject.id);
}
const nextWorkspace = nextProject.workspaces.find((item) => item.id === selectedWorkspaceId)
?? nextProject.primaryWorkspace
?? nextProject.workspaces[0]
?? null;
setSelectedWorkspaceId(nextWorkspace?.id ?? null);
}, [projectsQuery.data, projectsWithWorkspaces, selectedProject, selectedProjectId, selectedWorkspaceId, source]);
useEffect(() => {
setSelectedProjectId(initialProjectId ?? null);
setSelectedWorkspaceId(initialWorkspaceId ?? null);
}, [initialProjectId, initialWorkspaceId]);
useEffect(() => {
setFolderPath(effectiveInitialFolderPath ?? null);
setSearchInput(initialQuery ?? "");
setDebouncedQuery(initialQuery?.trim() ?? "");
}, [effectiveInitialFolderPath, initialQuery]);
useEffect(() => {
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, source, selectedProjectId, selectedWorkspaceId, folderPath]);
useEffect(() => {
setExpandedLazyFolders(new Set());
setFolderPageCounts({});
}, [workspace, source, selectedProjectId, selectedWorkspaceId, folderPath, debouncedQuery]);
const q = debouncedQuery || null;
const isSearch = q !== null;
const mode: WorkspaceFileListMode = folderPath || isSearch || selectedPath ? "all" : recentUnavailable ? "all" : "changed";
const targetProjectId = source === "other" ? selectedProjectId : null;
const targetWorkspaceId = source === "other" ? selectedWorkspaceId : null;
const effectiveWorkspace: WorkspaceFileSelector = source === "other" ? "project" : workspace;
const canListFiles = source === "current" || Boolean(targetProjectId && targetWorkspaceId);
const queriesEnabled = active && pageVisible && canListFiles;
const targetRef = targetProjectId && targetWorkspaceId
? { projectId: targetProjectId, workspaceId: targetWorkspaceId }
: {};
useEffect(() => {
onBrowseStateChange?.({
q: searchInput.trim() || null,
folderPath,
projectId: targetProjectId,
workspaceId: targetWorkspaceId,
});
}, [folderPath, onBrowseStateChange, searchInput, targetProjectId, targetWorkspaceId]);
const listQueryKey = useMemo(() => queryKeys.issues.fileResources(issueId, {
workspace: effectiveWorkspace,
projectId: targetProjectId,
workspaceId: targetWorkspaceId,
mode,
q,
limit: LIST_LIMIT,
offset: 0,
path: folderPath,
}), [effectiveWorkspace, folderPath, issueId, mode, q, targetProjectId, targetWorkspaceId]);
const listQuery = useQuery({
queryKey: listQueryKey,
queryFn: ({ signal }) => fileResourcesApi.list(issueId, {
workspace: effectiveWorkspace,
projectId: targetProjectId,
workspaceId: targetWorkspaceId,
mode,
q,
limit: LIST_LIMIT,
offset: 0,
path: folderPath,
}, { signal }),
enabled: queriesEnabled,
staleTime: 15_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
useEffect(() => {
if (active && pageVisible) return;
void queryClient.cancelQueries({
queryKey: ["issues", "file-resources", issueId, "list"],
});
}, [active, issueId, pageVisible, queryClient]);
const data = listQuery.data;
const items = useMemo(() => data?.items ?? [], [data]);
const workspaceLabel = data?.workspace?.workspaceLabel ?? null;
const isLazyBrowse = data?.query.mode === "all" && !q;
const currentFolderKey = folderKey(folderPath);
const selectedFolders = useMemo(
() => selectedAncestorFolders(selectedPath, folderPath),
[folderPath, selectedPath],
);
const forcedExpandedFolders = useMemo(() => new Set(selectedFolders), [selectedFolders]);
const loadedLazyFolders = useMemo(() => {
if (!isLazyBrowse) return [];
return Array.from(new Set([...expandedLazyFolders, ...selectedFolders]))
.filter((path) => path !== currentFolderKey)
.sort();
}, [currentFolderKey, expandedLazyFolders, isLazyBrowse, selectedFolders]);
const folderPageSpecs = useMemo(() => {
if (!canListFiles || !isLazyBrowse) return [];
const specs: Array<{ path: string; pageIndex: number; offset: number }> = [];
for (const path of loadedLazyFolders) {
const pageCount = folderPageCounts[path] ?? 1;
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
specs.push({ path, pageIndex, offset: pageIndex * LIST_LIMIT });
}
}
const rootPageCount = folderPageCounts[currentFolderKey] ?? 1;
for (let pageIndex = 1; pageIndex < rootPageCount; pageIndex += 1) {
specs.push({ path: currentFolderKey, pageIndex, offset: pageIndex * LIST_LIMIT });
}
return specs;
}, [canListFiles, currentFolderKey, folderPageCounts, isLazyBrowse, loadedLazyFolders]);
const folderPageQueries = useQueries({
queries: folderPageSpecs.map((spec) => ({
queryKey: queryKeys.issues.fileResources(issueId, {
workspace: effectiveWorkspace,
projectId: targetProjectId,
workspaceId: targetWorkspaceId,
mode: "all",
q: null,
limit: LIST_LIMIT,
offset: spec.offset,
path: spec.path || null,
}),
queryFn: ({ signal }: { signal: AbortSignal }) => fileResourcesApi.list(issueId, {
workspace: effectiveWorkspace,
projectId: targetProjectId,
workspaceId: targetWorkspaceId,
mode: "all",
q: null,
limit: LIST_LIMIT,
offset: spec.offset,
path: spec.path || null,
}, { signal }),
enabled: queriesEnabled && isLazyBrowse,
staleTime: 15_000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})),
});
const lazyItemsByFolder = useMemo(() => {
const map = new Map<string, WorkspaceFileListItem[]>();
if (isLazyBrowse) map.set(currentFolderKey, [...items]);
folderPageSpecs.forEach((spec, index) => {
const response = folderPageQueries[index]?.data;
if (!response || response.state !== "available") return;
const current = map.get(spec.path) ?? [];
current.push(...response.items);
map.set(spec.path, current);
});
return map;
}, [currentFolderKey, folderPageQueries, folderPageSpecs, isLazyBrowse, items]);
const lazyTruncatedFolders = useMemo(() => {
const pageState = new Map<string, { pageIndex: number; truncated: boolean }>();
if (isLazyBrowse) pageState.set(currentFolderKey, { pageIndex: 0, truncated: Boolean(data?.truncated) });
folderPageSpecs.forEach((spec, index) => {
const response = folderPageQueries[index]?.data;
if (!response) return;
const existing = pageState.get(spec.path);
if (!existing || spec.pageIndex >= existing.pageIndex) {
pageState.set(spec.path, { pageIndex: spec.pageIndex, truncated: response.truncated });
}
});
const set = new Set<string>();
for (const [path, state] of pageState) {
if (state.truncated) set.add(path);
}
return set;
}, [currentFolderKey, data?.truncated, folderPageQueries, folderPageSpecs, isLazyBrowse]);
const lazyFetchingFolders = useMemo(() => {
const set = new Set<string>();
folderPageSpecs.forEach((spec, index) => {
if (folderPageQueries[index]?.isFetching) set.add(spec.path);
});
return set;
}, [folderPageQueries, folderPageSpecs]);
const lazyErroredFolders = useMemo(() => {
const set = new Set<string>();
folderPageSpecs.forEach((spec, index) => {
if (folderPageQueries[index]?.isError) set.add(spec.path);
});
return set;
}, [folderPageQueries, folderPageSpecs]);
const allLoadedItems = useMemo(() => {
if (!isLazyBrowse) return items;
return Array.from(lazyItemsByFolder.values()).flat();
}, [isLazyBrowse, items, lazyItemsByFolder]);
const allLoadedFileItems = useMemo(
() => allLoadedItems.filter((item): item is WorkspaceFileListFileItem => item.kind === "file"),
[allLoadedItems],
);
const treeNodes = useMemo(() => {
if (isLazyBrowse) {
return buildWorkspaceDirectoryTree(lazyItemsByFolder.get(currentFolderKey) ?? items);
}
return buildWorkspaceFileTree(items, folderPath);
}, [currentFolderKey, folderPath, isLazyBrowse, items, lazyItemsByFolder]);
const selectedItemIndex = selectedPath
? allLoadedFileItems.findIndex((item) =>
item.relativePath === selectedPath &&
(activeProjectId ? item.projectId === activeProjectId : true) &&
(activeWorkspaceId ? item.workspaceId === activeWorkspaceId : true)
)
: -1;
// 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(() => {
if (selectedPath) {
setHighlightedIndex(selectedItemIndex >= 0 ? selectedItemIndex : -1);
return;
}
setHighlightedIndex(allLoadedFileItems.length > 0 ? 0 : -1);
}, [allLoadedFileItems.length, q, workspace, source, selectedProjectId, selectedWorkspaceId, folderPath, selectedPath, selectedItemIndex]);
useEffect(() => {
if (!selectedPath || !isLazyBrowse) return;
const selectedFolderKey = folderKey(parentFolderPath(selectedPath));
if (selectedFolderKey !== currentFolderKey && !loadedLazyFolders.includes(selectedFolderKey)) return;
if (!lazyTruncatedFolders.has(selectedFolderKey)) return;
if (lazyFetchingFolders.has(selectedFolderKey) || lazyErroredFolders.has(selectedFolderKey)) return;
const loadedItems = lazyItemsByFolder.get(selectedFolderKey) ?? [];
const selectedLoaded = loadedItems.some((item) =>
item.kind === "file" &&
item.relativePath === selectedPath &&
(activeProjectId ? item.projectId === activeProjectId : true) &&
(activeWorkspaceId ? item.workspaceId === activeWorkspaceId : true)
);
if (selectedLoaded) return;
setFolderPageCounts((current) => ({
...current,
[selectedFolderKey]: (current[selectedFolderKey] ?? 1) + 1,
}));
}, [
activeProjectId,
activeWorkspaceId,
currentFolderKey,
isLazyBrowse,
lazyErroredFolders,
lazyFetchingFolders,
lazyItemsByFolder,
lazyTruncatedFolders,
loadedLazyFolders,
selectedPath,
]);
const announcement = useMemo(() => {
if (listQuery.isFetching) return "Loading workspace files…";
if (listQuery.isError) return "Unable to load workspace files.";
if (data?.state === "unavailable") return describeUnavailable(data.unavailableReason ?? "").title;
if (items.length === 0) return "No matching files.";
return `${items.length} item${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);
const target = { workspace: effectiveWorkspace, ...targetRef };
if (parsed?.resourceKind === "directory") {
setFolderPath(parsed.path);
setSearchInput("");
setDebouncedQuery("");
} else if (parsed) {
onOpen({
path: parsed.path,
...target,
line: parsed.line,
column: parsed.column,
browseFolderPath: folderPath,
browseQuery: searchInput.trim() || null,
});
} else {
onOpen({
path: value,
...target,
browseFolderPath: folderPath,
browseQuery: searchInput.trim() || null,
});
}
}
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((current) => (allLoadedFileItems.length === 0 ? -1 : Math.min(allLoadedFileItems.length - 1, current + 1)));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((current) => (allLoadedFileItems.length === 0 ? -1 : Math.max(0, current - 1)));
} else if (event.key === "Enter") {
event.preventDefault();
const item = highlightedIndex >= 0 ? allLoadedFileItems[highlightedIndex] : undefined;
if (item) {
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
onOpen({
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
browseFolderPath: folderPath,
browseQuery: searchInput.trim() || null,
});
}
else openTypedPath();
}
}
const highlightedItem = highlightedIndex >= 0 ? allLoadedFileItems[highlightedIndex] : undefined;
const highlightedItemKey = highlightedItem ? itemKey(highlightedItem) : null;
const selectedItem = selectedItemIndex >= 0 ? allLoadedFileItems[selectedItemIndex] : null;
const selectedItemKey = selectedItem ? itemKey(selectedItem) : null;
const selectedOptionId = selectedItemKey ? `${listboxId}-file-${selectedItemKey}` : null;
const activeOptionId = highlightedItemKey ? `${listboxId}-file-${highlightedItemKey}` : undefined;
useEffect(() => {
if (!selectedOptionId) return;
const scrollContainer = scrollContainerRef.current;
const selectedElement = document.getElementById(selectedOptionId);
if (!scrollContainer || !selectedElement) return;
const containerRect = scrollContainer.getBoundingClientRect();
const selectedRect = selectedElement.getBoundingClientRect();
if (selectedRect.top < containerRect.top || selectedRect.bottom > containerRect.bottom) {
selectedElement.scrollIntoView({ block: "nearest" });
}
}, [folderPath, allLoadedFileItems, selectedOptionId]);
function openFolder(path: string | null) {
setFolderPath(path);
setSearchInput("");
setDebouncedQuery("");
}
function openItem(item: WorkspaceFileListFileItem) {
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
onOpen({
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
browseFolderPath: folderPath,
browseQuery: searchInput.trim() || null,
});
}
function toggleFolder(key: string) {
if (isLazyBrowse) {
setExpandedLazyFolders((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return;
}
setCollapsedFolders((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function loadMoreFolder(path: string) {
setFolderPageCounts((current) => ({
...current,
[path]: (current[path] ?? 1) + 1,
}));
}
function handleHoverFile(item: WorkspaceFileListFileItem) {
const key = itemKey(item);
const index = allLoadedFileItems.findIndex((candidate) => itemKey(candidate) === key);
if (index >= 0) setHighlightedIndex(index);
}
function getDownloadUrl(item: WorkspaceFileListFileItem): string | null {
if (!item.capabilities.download) return null;
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
return fileResourcesApi.downloadUrl(issueId, {
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
});
}
function lazyChildren(path: string, depth: number) {
const children = buildWorkspaceDirectoryTree(lazyItemsByFolder.get(path) ?? []);
return children.map((node) => ({ ...node, depth }));
}
// Loaded files stay on screen while a refetch fails; an outage before the
// first load is the skeleton. A saturated git scan is a per-route condition
// with its own copy and Retry, not an outage, even though its status is transient.
const listKind = queryViewKind(listQuery);
const listErrorStatus = listQuery.error instanceof ApiError ? listQuery.error.status : 0;
const listErrorCode = listQuery.error instanceof ApiError && listQuery.error.body && typeof listQuery.error.body === "object"
? (listQuery.error.body as { code?: unknown }).code
: null;
const changedFilesTemporarilyUnavailable = listQuery.error != null && mode === "changed" && (
listErrorStatus === 503 ||
listErrorStatus === 504 ||
listErrorCode === "workspace_git_scan_saturated" ||
listErrorCode === "workspace_git_scan_timeout"
);
let body: ReactNode;
if (source === "other" && !companyId) {
body = (
<StateMessage
icon={<FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
title="No organization selected"
body="Choose an organization before browsing another project workspace."
/>
);
} else if (source === "other" && projectsQuery.isFetching && projectsWithWorkspaces.length === 0) {
body = (
<StateMessage
icon={<Loader2 aria-hidden="true" className="h-5 w-5 animate-spin text-muted-foreground" />}
title="Loading project workspaces"
body="Registered workspaces will appear here."
/>
);
} else if (source === "other" && !canListFiles) {
body = (
<StateMessage
icon={<FolderOpen aria-hidden="true" className="h-5 w-5 text-muted-foreground" />}
title="No project workspaces"
body="No same-organization project has a registered workspace to browse."
/>
);
} else if (!data && (listQuery.isFetching || (listKind === "reconnecting" && !changedFilesTemporarilyUnavailable))) {
body = (
<div className="space-y-1.5 py-2" aria-busy="true">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex items-center gap-2 px-2 py-1.5">
<div className="h-3.5 w-3.5 shrink-0 rounded bg-muted" />
<div className="h-3 flex-1 rounded bg-muted" style={{ maxWidth: `${80 - index * 8}%` }} />
</div>
))}
</div>
);
} else if (listKind === "error" || (changedFilesTemporarilyUnavailable && !data)) {
body = (
<StateMessage
icon={<AlertTriangle aria-hidden="true" className="h-5 w-5 text-amber-500" />}
title={changedFilesTemporarilyUnavailable ? "Changed files temporarily unavailable" : "Couldn't load files"}
body={
changedFilesTemporarilyUnavailable
? "Paperclip is limiting workspace scans to keep the server responsive. Try again in a moment."
: listErrorStatus === 404
? "Workspace browsing isn't available for this issue."
: describeError(listQuery.error, { action: "load workspace files" }).body
}
actions={changedFilesTemporarilyUnavailable ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={!queriesEnabled}
onClick={() => void listQuery.refetch()}
>
<RefreshCcw aria-hidden="true" className="mr-1 h-3 w-3" /> Retry
</Button>
) : null}
/>
);
} else if (data?.state === "unavailable") {
const detail = describeUnavailable(data.unavailableReason ?? "");
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={isSearch ? `No files match “${q}”` : "No recently changed files yet"}
body="Try searching by name or path."
/>
);
} else {
body = (
<WorkspaceFileTree
nodes={treeNodes}
listboxId={listboxId}
highlightedItemKey={highlightedItemKey}
selectedItemKey={selectedItemKey}
collapsedFolders={collapsedFolders}
expandedLazyFolders={expandedLazyFolders}
forcedExpandedFolders={forcedExpandedFolders}
getLazyChildren={lazyChildren}
isLazyFolderFetching={(path) => lazyFetchingFolders.has(path)}
isLazyFolderTruncated={(path) => lazyTruncatedFolders.has(path)}
onLoadMoreFolder={loadMoreFolder}
onToggleFolder={toggleFolder}
onOpen={openItem}
onHoverFile={handleHoverFile}
getDownloadUrl={getDownloadUrl}
/>
);
}
return (
<div className={cn("flex min-h-0 min-w-0 flex-col gap-2", className)}>
<div className="flex min-w-0 max-w-full items-center gap-1 overflow-hidden">
<div className="relative min-w-0 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
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={autoFocusSearch}
autoComplete="off"
spellCheck={false}
className="h-8 w-full max-w-full min-w-0 pl-8 font-mono text-xs"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => void listQuery.refetch()}
disabled={!queriesEnabled || listQuery.isFetching}
aria-label="Refresh workspace files"
title="Refresh workspace files"
className="h-8 w-8 shrink-0"
>
<RefreshCcw aria-hidden="true" className={cn("h-3.5 w-3.5", listQuery.isFetching && "animate-spin")} />
</Button>
</div>
<WorkspaceFileBreadcrumbs
rootLabel={selectedProject && selectedWorkspace ? `${selectedProject.name} / ${selectedWorkspace.name}` : workspaceLabel}
folderPath={folderPath}
onOpenFolder={openFolder}
/>
<div aria-live="polite" className="sr-only">
{announcement}
</div>
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto">{body}</div>
{lazyTruncatedFolders.has(currentFolderKey) || (data?.truncated && !isLazyBrowse) ? (
<div className="border-t border-border pt-2 text-(length:--text-micro) text-muted-foreground">
{isLazyBrowse ? (
<button
type="button"
onClick={() => loadMoreFolder(currentFolderKey)}
className="rounded px-1 py-0.5 text-left hover:bg-accent hover:text-foreground"
>
Load more from this folder
</button>
) : (
<>Showing first {items.length} — refine the search to narrow.</>
)}
</div>
) : null}
</div>
);
}