diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 0bb8009ca42..0a6c946ce6e 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -31,6 +31,7 @@ export interface InstanceExperimentalSettings { enableIsolatedWorkspaces: boolean; enableStreamlinedLeftNavigation: boolean; enableIssuePlanDecompositions: boolean; + enableExperimentalFileViewer: boolean; enableCloudSync: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 448aa107f23..e5c235cb645 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -40,6 +40,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), + enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 0b575d26303..95a760bcdfe 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -65,6 +65,7 @@ describe("instance settings routes", () => { enableEnvironments: false, enableIsolatedWorkspaces: false, enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -84,6 +85,7 @@ describe("instance settings routes", () => { enableEnvironments: true, enableIsolatedWorkspaces: true, enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, enableCloudSync: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -128,6 +130,7 @@ describe("instance settings routes", () => { enableEnvironments: false, enableIsolatedWorkspaces: false, enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index cc4f4a235f2..dd71dd5b3d8 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -7,6 +7,7 @@ describe("instance settings service", () => { enableEnvironments: true, enableIsolatedWorkspaces: true, enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, enableCloudSync: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, @@ -17,6 +18,7 @@ describe("instance settings service", () => { enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: false, enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, enableCloudSync: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index e6c15745069..170c8ca26e3 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -45,6 +45,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, + enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, @@ -58,6 +59,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: false, enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx index 8984d83a513..bdb71c67ff4 100644 --- a/ui/src/components/CommandPalette.test.tsx +++ b/ui/src/components/CommandPalette.test.tsx @@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CommandPalette } from "./CommandPalette"; +import { queryKeys } from "../lib/queryKeys"; function act(callback: () => void | Promise) { let result: void | Promise | undefined; @@ -41,6 +42,10 @@ const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn(), })); +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); @@ -57,10 +62,13 @@ vi.mock("../context/SidebarContext", () => ({ const navigateState = vi.hoisted(() => ({ navigate: vi.fn(), })); +const locationState = vi.hoisted(() => ({ + location: { pathname: "/", search: "", hash: "" }, +})); vi.mock("@/lib/router", () => ({ useNavigate: () => navigateState.navigate, - useLocation: () => ({ pathname: "/", search: "", hash: "" }), + useLocation: () => locationState.location, })); vi.mock("../api/issues", () => ({ @@ -75,6 +83,10 @@ vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi, })); +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => {name}, })); @@ -142,7 +154,11 @@ async function waitForAssertion(assertion: () => void, attempts = 20) { throw lastError; } -function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { +function renderWithQueryClient( + node: ReactNode, + container: HTMLDivElement, + seedQueryClient?: (queryClient: QueryClient) => void, +) { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { @@ -151,6 +167,7 @@ function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { }, }, }); + seedQueryClient?.(queryClient); act(() => { root.render( @@ -175,10 +192,17 @@ describe("CommandPalette", () => { mockIssuesApi.list.mockReset(); mockAgentsApi.list.mockReset(); mockProjectsApi.list.mockReset(); + mockInstanceSettingsApi.getExperimental.mockReset(); navigateState.navigate.mockReset(); + locationState.location.pathname = "/"; + locationState.location.search = ""; + locationState.location.hash = ""; mockIssuesApi.list.mockResolvedValue([]); mockAgentsApi.list.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableExperimentalFileViewer: false, + }); }); afterEach(() => { @@ -212,6 +236,52 @@ describe("CommandPalette", () => { }); }); + it("hides the issue file viewer command by default", async () => { + locationState.location.pathname = "/issues/PAP-1"; + const { root } = renderWithQueryClient(, container); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Create new task"); + }); + expect(container.textContent).not.toContain("Open file in this issue"); + + act(() => { + root.unmount(); + }); + }); + + it("shows the issue file viewer command when the experimental flag is enabled", async () => { + locationState.location.pathname = "/issues/PAP-1"; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableExperimentalFileViewer: true, + }); + const { root } = renderWithQueryClient( + , + container, + (queryClient) => { + queryClient.setQueryData(queryKeys.instance.experimentalSettings, { + enableExperimentalFileViewer: true, + }); + }, + ); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Open file in this issue"); + }); + + act(() => { + root.unmount(); + }); + }); + it("offers a Search-all command when the query is non-empty and routes Enter to /search when no issues match", async () => { mockIssuesApi.list.mockResolvedValue([]); const { root } = renderWithQueryClient(, container); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 49689ebd9ed..42d6af17f94 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -7,6 +7,7 @@ import { useSidebar } from "../context/SidebarContext"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { CommandDialog, @@ -57,6 +58,12 @@ export function CommandPalette() { const { isMobile, setSidebarOpen } = useSidebar(); const searchQuery = query.trim(); const onIssueDetail = isOnIssueDetail(location.pathname); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + retry: false, + }); + const fileViewerEnabled = experimentalSettings?.enableExperimentalFileViewer === true; useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -186,7 +193,7 @@ export function CommandPalette() { Create new task C - {onIssueDetail && ( + {onIssueDetail && fileViewerEnabled && ( { setOpen(false); diff --git a/ui/src/components/WorkspaceFileMarkdownBody.tsx b/ui/src/components/WorkspaceFileMarkdownBody.tsx index 6a4d98a294c..3011d3e2e66 100644 --- a/ui/src/components/WorkspaceFileMarkdownBody.tsx +++ b/ui/src/components/WorkspaceFileMarkdownBody.tsx @@ -43,7 +43,7 @@ export function WorkspaceFileMarkdownBody({ return (
- {children} + {children}
); } diff --git a/ui/src/context/FileViewerContext.tsx b/ui/src/context/FileViewerContext.tsx index 410471490b1..01e42093f68 100644 --- a/ui/src/context/FileViewerContext.tsx +++ b/ui/src/context/FileViewerContext.tsx @@ -93,9 +93,15 @@ export function readBrowseStateFromSearch(search: string): FileViewerBrowseState interface FileViewerProviderProps { issueId: string; children: ReactNode; + enabled?: boolean; } -export function FileViewerProvider({ issueId, children }: FileViewerProviderProps) { +export function FileViewerProvider({ issueId, children, enabled = true }: FileViewerProviderProps) { + if (!enabled) return <>{children}; + return {children}; +} + +function EnabledFileViewerProvider({ issueId, children }: Omit) { const location = useLocation(); const navigate = useNavigate(); const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index bd888cabb71..be340025bd0 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -210,6 +210,8 @@ export function InstanceExperimentalSettings() { experimentalQuery.data?.enableStreamlinedLeftNavigation === true; const enableIssuePlanDecompositions = experimentalQuery.data?.enableIssuePlanDecompositions === true; + const enableExperimentalFileViewer = + experimentalQuery.data?.enableExperimentalFileViewer === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; const enableIssueGraphLivenessAutoRecovery = @@ -286,6 +288,27 @@ export function InstanceExperimentalSettings() { +
+
+
+

Experimental File Viewer

+

+ Show task detail controls for browsing and previewing workspace files relative to a task. +

+
+ + toggleMutation.mutate({ + enableExperimentalFileViewer: !enableExperimentalFileViewer, + }) + } + disabled={toggleMutation.isPending} + aria-label="Toggle experimental file viewer setting" + /> +
+
+
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 1ed1726a8b1..e14b3df140f 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -75,6 +75,7 @@ const mockPushToast = vi.hoisted(() => vi.fn()); const mockIssuesListRender = vi.hoisted(() => vi.fn()); const mockIssueChatThreadRender = vi.hoisted(() => vi.fn()); const mockImageGalleryRender = vi.hoisted(() => vi.fn()); +const mockIssueWorkspaceCardRender = vi.hoisted(() => vi.fn()); vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi, @@ -282,7 +283,10 @@ vi.mock("../components/IssueRunLedger", () => ({ })); vi.mock("../components/IssueWorkspaceCard", () => ({ - IssueWorkspaceCard: () =>
Workspace
, + IssueWorkspaceCard: (props: { onBrowseFiles?: () => void; onOpenFileByPath?: () => void }) => { + mockIssueWorkspaceCardRender(props); + return
Workspace
; + }, })); vi.mock("../components/ImageGalleryModal", () => ({ @@ -934,11 +938,13 @@ describe("IssueDetail", () => { }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, }); mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]); mockIssuesListRender.mockClear(); mockIssueChatThreadRender.mockClear(); mockImageGalleryRender.mockClear(); + mockIssueWorkspaceCardRender.mockClear(); }); afterEach(async () => { @@ -994,10 +1000,55 @@ describe("IssueDetail", () => { expect(mockIssuesApi.listAcceptedPlanDecompositions).not.toHaveBeenCalled(); }); + it("hides file viewer entry points by default", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue()); + + await act(async () => { + root.render( + + + , + ); + }); + + await flushReact(); + await flushReact(); + + expect(container.querySelector('[aria-label="Open file in this issue"]')).toBeNull(); + const latestWorkspaceProps = mockIssueWorkspaceCardRender.mock.calls.at(-1)?.[0]; + expect(latestWorkspaceProps?.onBrowseFiles).toBeUndefined(); + expect(latestWorkspaceProps?.onOpenFileByPath).toBeUndefined(); + }); + + it("shows file viewer entry points when the experimental flag is enabled", async () => { + mockIssuesApi.get.mockResolvedValue(createIssue()); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: true, + }); + + await act(async () => { + root.render( + + + , + ); + }); + + await flushReact(); + await flushReact(); + + expect(container.querySelector('[aria-label="Open file in this issue"]')).not.toBeNull(); + const latestWorkspaceProps = mockIssueWorkspaceCardRender.mock.calls.at(-1)?.[0]; + expect(latestWorkspaceProps?.onBrowseFiles).toEqual(expect.any(Function)); + expect(latestWorkspaceProps?.onOpenFileByPath).toEqual(expect.any(Function)); + }); + it("shows the plan decomposition panel when the experimental flag is enabled", async () => { mockIssuesApi.get.mockResolvedValue(createIssue()); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: false, }); mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([ { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 3686fbf36d3..bec1be1b85a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1520,6 +1520,7 @@ export function IssueDetail() { const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt"; const showPlanDecompositionsSection = instanceExperimentalSettings?.enableIssuePlanDecompositions === true; + const fileViewerEnabled = instanceExperimentalSettings?.enableExperimentalFileViewer === true; const { orderedProjects } = useProjectOrder({ projects: projects ?? [], companyId: selectedCompanyId, @@ -2952,6 +2953,7 @@ export function IssueDetail() { setPendingCommentComposerFocusKey((current) => current + 1); } if (action === "open_file_viewer") { + if (!fileViewerEnabled) return; event.preventDefault(); event.stopPropagation(); setFileViewerPromptOpen(true); @@ -2967,7 +2969,7 @@ export function IssueDetail() { document.removeEventListener("focusin", handleFocusIn, true); document.removeEventListener("keydown", handleKeyDown, true); }; - }, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]); + }, [fileViewerEnabled, keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]); useEffect(() => { const hash = location.hash; @@ -3016,6 +3018,7 @@ export function IssueDetail() { }, [detailTab, pendingCommentComposerFocusKey]); useEffect(() => { + if (!fileViewerEnabled) return; const handleOpenFileViewer = () => { setFileViewerPromptOpen(true); }; @@ -3026,7 +3029,7 @@ export function IssueDetail() { handleOpenFileViewer as EventListener, ); }; - }, []); + }, [fileViewerEnabled]); const promotedOutputAttachmentIds = useMemo(() => getPromotedOutputAttachmentIds(workProducts), [workProducts]); const attachmentList = useMemo( @@ -3469,7 +3472,7 @@ export function IssueDetail() { ); return ( - +
{/* Parent chain breadcrumb */} {ancestors.length > 0 && ( @@ -3719,15 +3722,17 @@ export function IssueDetail() { )} - + {fileViewerEnabled ? ( + + ) : null}