diff --git a/ui/src/api/file-resources.ts b/ui/src/api/file-resources.ts index a333757e86d..537e845d936 100644 --- a/ui/src/api/file-resources.ts +++ b/ui/src/api/file-resources.ts @@ -10,10 +10,14 @@ import { api } from "./client"; export interface FileResourceQuery { path: string; workspace?: WorkspaceFileSelector; + projectId?: string | null; + workspaceId?: string | null; } export interface FileResourceListQuery { workspace?: WorkspaceFileSelector; + projectId?: string | null; + workspaceId?: string | null; mode?: WorkspaceFileListMode; q?: string | null; limit?: number; @@ -21,6 +25,10 @@ export interface FileResourceListQuery { function buildQuery(query: FileResourceQuery | FileResourceListQuery): string { const params = new URLSearchParams(); + if (query.projectId && query.workspaceId) { + params.set("projectId", query.projectId); + params.set("workspaceId", query.workspaceId); + } if ("path" in query) params.set("path", query.path); if (query.workspace && query.workspace !== "auto") { params.set("workspace", query.workspace); diff --git a/ui/src/components/ArtifactFileChip.tsx b/ui/src/components/ArtifactFileChip.tsx index 8012eb6f39f..a351c312568 100644 --- a/ui/src/components/ArtifactFileChip.tsx +++ b/ui/src/components/ArtifactFileChip.tsx @@ -15,6 +15,12 @@ export interface ArtifactFileChipProps { title?: string; } +function artifactFileDisplay(ref: WorkspaceFileRef) { + if (!ref.projectName) return ref.displayPath; + const prefix = `${ref.projectName} / `; + return ref.displayPath.startsWith(prefix) ? ref.displayPath : `${prefix}${ref.displayPath}`; +} + export function ArtifactFileChip({ workspaceFileRef, label, @@ -24,7 +30,7 @@ export function ArtifactFileChip({ title, }: ArtifactFileChipProps) { const viewer = useFileViewer(); - const display = typeof label !== "undefined" ? label : workspaceFileRef.displayPath; + const display = typeof label !== "undefined" ? label : artifactFileDisplay(workspaceFileRef); const canOpen = !!(onOpen || viewer); const lineSuffix = workspaceFileRef.line ? ` line ${workspaceFileRef.line}${workspaceFileRef.column ? ` column ${workspaceFileRef.column}` : ""}` @@ -61,6 +67,8 @@ export function ArtifactFileChip({ line: workspaceFileRef.line ?? null, column: workspaceFileRef.column ?? null, workspace, + projectId: workspaceFileRef.projectId ?? null, + workspaceId: workspaceFileRef.projectId ? workspaceFileRef.workspaceId : null, }); } }; diff --git a/ui/src/components/FileViewerSheet.tsx b/ui/src/components/FileViewerSheet.tsx index 2b9545f3bda..3a050b8a409 100644 --- a/ui/src/components/FileViewerSheet.tsx +++ b/ui/src/components/FileViewerSheet.tsx @@ -342,6 +342,7 @@ function LoadingView({ elapsedMs }: { elapsedMs: number }) { interface FileViewerSheetProps { issueId: string; + companyId?: string | null; /** When not provided, the sheet defaults to the context state. */ state?: FileViewerUrlState | null; /** When true, renders the "Open file" prompt when no file is selected but sheet is open. */ @@ -353,6 +354,7 @@ interface FileViewerSheetProps { export function FileViewerSheet({ issueId, + companyId, state: stateProp, showPromptWhenEmpty = false, open: openProp, @@ -383,13 +385,13 @@ export function FileViewerSheet({ }, 75); return () => window.clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state?.path, state?.workspace]); + }, [state?.path, state?.workspace, state?.projectId, state?.workspaceId]); const resolveQuery = useQuery({ queryKey: state - ? queryKeys.issues.fileResource(issueId, state.path, state.workspace) + ? queryKeys.issues.fileResource(issueId, state) : ["issues", "file-resources", issueId, "resolve", "__closed__"], - queryFn: () => fileResourcesApi.resolve(issueId, { path: state!.path, workspace: state!.workspace }), + queryFn: () => fileResourcesApi.resolve(issueId, state!), enabled: !!state && computedOpen, retry: false, staleTime: 30_000, @@ -400,9 +402,9 @@ export function FileViewerSheet({ const contentQuery = useQuery({ queryKey: state - ? queryKeys.issues.fileResourceContent(issueId, state.path, state.workspace) + ? queryKeys.issues.fileResourceContent(issueId, state) : ["issues", "file-resources", issueId, "content", "__closed__"], - queryFn: () => fileResourcesApi.content(issueId, { path: state!.path, workspace: state!.workspace }), + queryFn: () => fileResourcesApi.content(issueId, state!), enabled: !!state && computedOpen && canPreview, retry: false, staleTime: 30_000, @@ -434,9 +436,23 @@ export function FileViewerSheet({ ); const handleBrowseOpen = useCallback( - (ref: { path: string; workspace: WorkspaceFileSelector; line?: number | null; column?: number | null }) => { + (ref: { + path: string; + workspace: WorkspaceFileSelector; + line?: number | null; + column?: number | null; + projectId?: string | null; + workspaceId?: string | null; + }) => { viewer.open( - { path: ref.path, line: ref.line ?? null, column: ref.column ?? null, workspace: ref.workspace }, + { + path: ref.path, + line: ref.line ?? null, + column: ref.column ?? null, + workspace: ref.workspace, + projectId: ref.projectId ?? null, + workspaceId: ref.workspaceId ?? null, + }, { fromBrowse: true }, ); }, @@ -619,13 +635,15 @@ export function FileViewerSheet({ onRetry={handleRetry} onSetAnnouncement={setAnnouncement} onFallbackToProject={ - state.workspace !== "project" + state.workspace !== "project" && !state.projectId && !state.workspaceId ? () => viewer.open({ path: state.path, line: state.line, column: state.column, workspace: "project", + projectId: null, + workspaceId: null, }) : null } @@ -633,8 +651,11 @@ export function FileViewerSheet({ ) : browseMode ? ( ) : null} diff --git a/ui/src/components/WorkspaceFileBrowser.test.tsx b/ui/src/components/WorkspaceFileBrowser.test.tsx index 987bc151f76..a0f0c406e16 100644 --- a/ui/src/components/WorkspaceFileBrowser.test.tsx +++ b/ui/src/components/WorkspaceFileBrowser.test.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom +import type { ComponentProps } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; -import type { WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared"; +import type { Project, ProjectWorkspace, WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkspaceFileBrowser, describeUnavailable } from "./WorkspaceFileBrowser"; @@ -64,6 +65,72 @@ function availableResponse(items: WorkspaceFileListItem[], truncated = false): W }; } +function createWorkspace(overrides: Partial = {}): ProjectWorkspace { + return { + id: "workspace-content", + companyId: "company-1", + projectId: "project-content", + name: "Paperclip Content", + sourceType: "local_path", + cwd: "/srv/paperclip/home/paperclipai/paperclip-content", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: true, + createdAt: new Date("2026-06-08T00:00:00.000Z"), + updatedAt: new Date("2026-06-08T00:00:00.000Z"), + ...overrides, + }; +} + +function createProject(overrides: Partial = {}): Project { + const workspace = createWorkspace(); + return { + id: "project-content", + companyId: "company-1", + urlKey: "paperclip-content", + goalId: null, + goalIds: [], + goals: [], + name: "Paperclip Content", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: null, + icon: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: workspace.id, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: workspace.cwd, + managedFolder: "", + effectiveLocalFolder: workspace.cwd ?? "", + origin: "local_folder", + }, + workspaces: [workspace], + primaryWorkspace: workspace, + archivedAt: null, + createdAt: new Date("2026-06-08T00:00:00.000Z"), + updatedAt: new Date("2026-06-08T00:00:00.000Z"), + ...overrides, + }; +} + function unavailableResponse(reason: string): WorkspaceFileListResponse { return { kind: "workspace_file_list", @@ -77,7 +144,7 @@ function unavailableResponse(reason: string): WorkspaceFileListResponse { }; } -function ok(data: WorkspaceFileListResponse) { +function ok(data: T) { return { data, isFetching: false, isError: false, error: null, refetch: vi.fn() }; } @@ -100,11 +167,11 @@ describe("WorkspaceFileBrowser", () => { container.remove(); }); - function renderBrowser(onOpen = vi.fn()) { + function renderBrowser(onOpen = vi.fn(), props: Partial> = {}) { const root = createRoot(container); roots.push(root); act(() => { - root.render(); + root.render(); }); return { root, onOpen }; } @@ -169,6 +236,48 @@ describe("WorkspaceFileBrowser", () => { renderBrowser(); expect(container.textContent).toContain("No workspace yet"); }); + + it("opens a result from a selected other project workspace", () => { + const contentItem = createItem({ + relativePath: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md", + displayPath: "Paperclip Content / content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md", + workspaceLabel: "Paperclip Content", + workspaceKind: "project_workspace", + workspaceId: "workspace-content", + projectId: "project-content", + projectName: "Paperclip Content", + }); + useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => { + if (options.queryKey[0] === "projects") return ok([createProject()]); + return ok(availableResponse([contentItem])); + }); + + const { onOpen } = renderBrowser(vi.fn(), { + companyId: "company-1", + initialProjectId: "project-content", + initialWorkspaceId: "workspace-content", + }); + + expect(container.textContent).toContain("Other project"); + expect(container.textContent).toContain("Browsing Paperclip Content / Paperclip Content"); + const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list"); + expect(listCall?.[0].queryKey[4]).toMatchObject({ + workspace: "project", + projectId: "project-content", + workspaceId: "workspace-content", + }); + + const option = container.querySelector('[role="option"]')!; + act(() => { + option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onOpen).toHaveBeenCalledWith({ + path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md", + workspace: "project", + projectId: "project-content", + workspaceId: "workspace-content", + }); + }); }); describe("describeUnavailable", () => { diff --git a/ui/src/components/WorkspaceFileBrowser.tsx b/ui/src/components/WorkspaceFileBrowser.tsx index 3ff6e7a820d..f5bb495279b 100644 --- a/ui/src/components/WorkspaceFileBrowser.tsx +++ b/ui/src/components/WorkspaceFileBrowser.tsx @@ -10,13 +10,16 @@ import { import { useQuery } from "@tanstack/react-query"; import { AlertTriangle, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { timeAgo } from "@/lib/timeAgo"; import { fileResourcesApi } from "@/api/file-resources"; +import { projectsApi } from "@/api/projects"; import { ApiError } from "@/api/client"; import { queryKeys } from "@/lib/queryKeys"; import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser"; import type { + Project, WorkspaceFileListItem, WorkspaceFileListMode, WorkspaceFileSelector, @@ -28,6 +31,8 @@ const WORKSPACE_OPTIONS: ReadonlyArray<{ value: WorkspaceFileSelector; label: st { value: "project", label: "Project" }, ]; +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. @@ -118,6 +123,36 @@ function WorkspaceSelector({ ); } +function SourceSelector({ + value, + onChange, +}: { + value: BrowserSource; + onChange: (next: BrowserSource) => void; +}) { + return ( +
+ {[ + { value: "current" as const, label: "Current workspace" }, + { value: "other" as const, label: "Other project" }, + ].map((option) => ( + + ))} +
+ ); +} + interface WorkspaceFileRowProps { item: WorkspaceFileListItem; optionId: string; @@ -170,19 +205,37 @@ function WorkspaceFileRow({ item, optionId, selected, showTimestamp, onOpen, onH 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; }) => void; /** Seed the search field (e.g. from a URL-backed deep link). */ initialQuery?: string | null; + initialProjectId?: string | null; + initialWorkspaceId?: string | null; className?: string; } -export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className }: WorkspaceFileBrowserProps) { +export function WorkspaceFileBrowser({ + issueId, + companyId, + onOpen, + initialQuery, + initialProjectId, + initialWorkspaceId, + className, +}: WorkspaceFileBrowserProps) { + const [source, setSource] = useState( + initialProjectId && initialWorkspaceId ? "other" : "current", + ); const [workspace, setWorkspace] = useState("auto"); + const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId ?? null); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(initialWorkspaceId ?? 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 @@ -193,6 +246,49 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className const listboxId = useId(); const inputRef = useRef(null); + const projectsQuery = useQuery({ + queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__none__"], + queryFn: () => projectsApi.list(companyId!), + enabled: source === "other" && !!companyId, + retry: false, + 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(() => { const handle = window.setTimeout(() => setDebouncedQuery(searchInput.trim()), 150); return () => window.clearTimeout(handle); @@ -201,15 +297,37 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className // A new search or workspace should re-attempt recent-change tracking. useEffect(() => { setRecentUnavailable(false); - }, [workspace]); + }, [workspace, source, selectedProjectId, selectedWorkspaceId]); const q = debouncedQuery || null; const isSearch = q !== null; const mode: WorkspaceFileListMode = isSearch ? "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 targetRef = targetProjectId && targetWorkspaceId + ? { projectId: targetProjectId, workspaceId: targetWorkspaceId } + : {}; 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: effectiveWorkspace, + projectId: targetProjectId, + workspaceId: targetWorkspaceId, + mode, + q, + limit: LIST_LIMIT, + }), + queryFn: () => fileResourcesApi.list(issueId, { + workspace: effectiveWorkspace, + projectId: targetProjectId, + workspaceId: targetWorkspaceId, + mode, + q, + limit: LIST_LIMIT, + }), + enabled: canListFiles, retry: false, staleTime: 15_000, }); @@ -228,7 +346,7 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className // Keep the highlighted option valid as results change. useEffect(() => { setHighlightedIndex(items.length > 0 ? 0 : -1); - }, [items, q, workspace]); + }, [items, q, workspace, source, selectedProjectId, selectedWorkspaceId]); const announcement = useMemo(() => { if (listQuery.isFetching) return "Loading workspace files…"; @@ -242,8 +360,9 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className 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 }); + const target = { workspace: effectiveWorkspace, ...targetRef }; + if (parsed) onOpen({ path: parsed.path, ...target, line: parsed.line, column: parsed.column }); + else onOpen({ path: value, ...target }); } function handleSearchKeyDown(event: KeyboardEvent) { @@ -256,15 +375,54 @@ export function WorkspaceFileBrowser({ issueId, onOpen, initialQuery, className } else if (event.key === "Enter") { event.preventDefault(); const item = highlightedIndex >= 0 ? items[highlightedIndex] : undefined; - if (item) onOpen({ path: item.relativePath, workspace }); + if (item) { + const itemTarget = item.projectId + ? { projectId: item.projectId, workspaceId: item.workspaceId } + : targetRef; + onOpen({ + path: item.relativePath, + workspace: effectiveWorkspace, + ...itemTarget, + }); + } else openTypedPath(); } } + function handleProjectChange(projectId: string) { + const project = projectsWithWorkspaces.find((item) => item.id === projectId) ?? null; + setSelectedProjectId(project?.id ?? null); + setSelectedWorkspaceId(project?.primaryWorkspace?.id ?? project?.workspaces[0]?.id ?? null); + } + const activeOptionId = highlightedIndex >= 0 ? `${listboxId}-opt-${highlightedIndex}` : undefined; let body: ReactNode; - if (listQuery.isFetching && !data) { + if (source === "other" && !companyId) { + body = ( +