Gate file viewer UI behind experimental flag

This commit is contained in:
Dotta committed 2026-06-09 21:35:48 +00:00
1 parent 8f6f8ce205
commit f67c02f894
12 files changed
+199 -26

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ export interface InstanceExperimentalSettings {
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
@@ -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),
@@ -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,
@@ -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,
+2
View File
@@ -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,
+72 -2
View File
@@ -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<void>) {
let result: void | Promise<void> | 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 }) => <span>{name}</span>,
}));
@@ -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(<CommandPalette />, 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(
<CommandPalette />,
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(<CommandPalette />, container);
+8 -1
View File
@@ -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
<span className="ml-auto text-xs text-muted-foreground">C</span>
</CommandItem>
{onIssueDetail && (
{onIssueDetail && fileViewerEnabled && (
<CommandItem
onSelect={() => {
setOpen(false);
@@ -43,7 +43,7 @@ export function WorkspaceFileMarkdownBody({
return (
<div onClick={handleClick}>
<MarkdownBody {...props} linkWorkspaceFileRefs>{children}</MarkdownBody>
<MarkdownBody {...props} linkWorkspaceFileRefs={!!viewer}>{children}</MarkdownBody>
</div>
);
}
+7 -1
View File
@@ -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 <EnabledFileViewerProvider issueId={issueId}>{children}</EnabledFileViewerProvider>;
}
function EnabledFileViewerProvider({ issueId, children }: Omit<FileViewerProviderProps, "enabled">) {
const location = useLocation();
const navigate = useNavigate();
const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]);
@@ -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() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Experimental File Viewer</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show task detail controls for browsing and previewing workspace files relative to a task.
</p>
</div>
<ToggleSwitch
checked={enableExperimentalFileViewer}
onCheckedChange={() =>
toggleMutation.mutate({
enableExperimentalFileViewer: !enableExperimentalFileViewer,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle experimental file viewer setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
+52 -1
View File
@@ -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: () => <div>Workspace</div>,
IssueWorkspaceCard: (props: { onBrowseFiles?: () => void; onOpenFileByPath?: () => void }) => {
mockIssueWorkspaceCardRender(props);
return <div>Workspace</div>;
},
}));
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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
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([
{
+27 -20
View File
@@ -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 (
<FileViewerProvider issueId={issue.id}>
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<div className="max-w-3xl space-y-6">
{/* Parent chain breadcrumb */}
{ancestors.length > 0 && (
@@ -3719,15 +3722,17 @@ export function IssueDetail() {
<Archive className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
onClick={() => setFileViewerPromptOpen(true)}
title="Open file... (g f)"
aria-label="Open file in this issue"
>
<FileCode2 className="h-4 w-4" />
</Button>
{fileViewerEnabled ? (
<Button
variant="ghost"
size="icon-xs"
onClick={() => setFileViewerPromptOpen(true)}
title="Open file... (g f)"
aria-label="Open file in this issue"
>
<FileCode2 className="h-4 w-4" />
</Button>
) : null}
<Button
variant="ghost"
size="icon-xs"
@@ -4052,11 +4057,11 @@ export function IssueDetail() {
issue={issue}
project={resolvedProject}
onUpdate={(data) => updateIssue.mutate(data)}
onBrowseFiles={() => setFileViewerPromptOpen(true)}
onOpenFileByPath={() => setFileViewerPromptOpen(true)}
onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
/>
{issue.workProducts && issue.workProducts.length > 0 && (() => {
{fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
const workProductsWithFileRefs = issue.workProducts
.map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) }))
.filter(({ fileRef }) => fileRef !== null);
@@ -4397,11 +4402,13 @@ export function IssueDetail() {
</ScrollArea>
</SheetContent>
</Sheet>
<IssueFileViewer
issueId={issue.id}
promptOpen={fileViewerPromptOpen}
onPromptOpenChange={setFileViewerPromptOpen}
/>
{fileViewerEnabled ? (
<IssueFileViewer
issueId={issue.id}
promptOpen={fileViewerPromptOpen}
onPromptOpenChange={setFileViewerPromptOpen}
/>
) : null}
<ScrollToBottom />
</div>
</FileViewerProvider>