diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx
index 1d704f60ee..de65df1593 100644
--- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx
+++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx
@@ -458,11 +458,16 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanelTabContent", () => ({
HostScopedFilePreviewTabContent: ({
activePath,
hostId,
+ isPanelOpen,
}: {
activePath: string;
hostId: string;
+ isPanelOpen: boolean;
}) => (
-
+
host:{hostId}:{activePath}
),
@@ -858,6 +863,15 @@ describe("PluginPanelRightPanelHost", () => {
expect(
await screen.findByText("host:host-explicit:/tmp/example.log"),
).toBeTruthy();
+ expect(
+ screen.getByTestId("host-scoped-file-preview").dataset.panelOpen,
+ ).toBe("true");
+ fireEvent.click(screen.getByRole("button", { name: "Hide right panel" }));
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("host-scoped-file-preview").dataset.panelOpen,
+ ).toBe("false");
+ });
fireEvent.click(screen.getByRole("button", { name: "Open storage file" }));
expect(
diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
index 5a6792c400..b4e32b713f 100644
--- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
+++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
@@ -725,6 +725,7 @@ export function PluginPanelRightPanelHost({
);
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx
index 71d58d5875..99df92ff3b 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx
@@ -10,16 +10,21 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
environmentDiffFilesQueryKeyPrefix,
environmentFilePreviewQueryKeyPrefix,
+ hostFilePreviewQueryKey,
} from "@/hooks/queries/query-keys";
import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import {
GitDiffTabContent,
+ HostScopedFilePreviewTabContent,
WorkspaceFilePreviewTabContent,
} from "./ThreadSecondaryPanelTabContent";
vi.mock("@/lib/sdk", () => ({
- sdk: { environments: { diffFiles: vi.fn(), diffFile: vi.fn() } },
+ sdk: {
+ environments: { diffFiles: vi.fn(), diffFile: vi.fn() },
+ files: { createPreview: vi.fn(), read: vi.fn() },
+ },
}));
// The preview body is not under test; keep pierre out of jsdom.
@@ -69,10 +74,10 @@ describe("GitDiffTabContent panel gating", () => {
isDiffPanelActive
isPanelOpen={isPanelOpen}
gitDiffPresentation={{
- view: "unified",
- overflow: "scroll",
- showLineNumbers: true,
- }}
+ view: "unified",
+ overflow: "scroll",
+ showLineNumbers: true,
+ }}
/>
);
@@ -141,3 +146,54 @@ describe("WorkspaceFilePreviewTabContent panel gating", () => {
});
});
});
+
+describe("HostScopedFilePreviewTabContent panel gating", () => {
+ it("does not start or refetch a host read while the retained panel is closed", async () => {
+ vi.mocked(sdk.files.createPreview).mockResolvedValue({
+ baseUrl: "/api/v1/file-previews/lease-1",
+ expiresAtMs: Date.now() + 60_000,
+ });
+ vi.mocked(sdk.files.read).mockResolvedValue({
+ path: "/tmp/example.txt",
+ content: "hello\n",
+ contentEncoding: "utf8",
+ mimeType: "text/plain",
+ modifiedAtMs: 1,
+ sha256: "hash",
+ sizeBytes: 6,
+ });
+ const { queryClient, wrapper: Wrapper } = createQueryClientTestHarness();
+ const renderTab = (isPanelOpen: boolean) => (
+
+
+
+ );
+
+ const view = render(renderTab(false));
+ expect(sdk.files.read).not.toHaveBeenCalled();
+ expect(sdk.files.createPreview).not.toHaveBeenCalled();
+
+ view.rerender(renderTab(true));
+ await waitFor(() => {
+ expect(sdk.files.read).toHaveBeenCalledTimes(1);
+ });
+
+ view.rerender(renderTab(false));
+ await act(async () => {
+ await queryClient.invalidateQueries({
+ queryKey: hostFilePreviewQueryKey("host-1", "/tmp/example.txt"),
+ });
+ });
+ expect(sdk.files.read).toHaveBeenCalledTimes(1);
+
+ view.rerender(renderTab(true));
+ await waitFor(() => {
+ expect(sdk.files.read).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
index 71939327c5..e132960702 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
@@ -130,6 +130,11 @@ export interface HostFilePreviewTabContentProps {
export interface HostScopedFilePreviewTabContentProps {
activePath: string;
hostId: string;
+ /**
+ * Whether the secondary panel is open. The retained panel body stays
+ * mounted while closed, but its host read must pause until it is visible.
+ */
+ isPanelOpen: boolean;
lineRange: FilePreviewLineRange | null;
onOpenInEditor?: (path: string) => void;
}
@@ -493,6 +498,7 @@ export function HostFilePreviewTabContent({
export function HostScopedFilePreviewTabContent({
activePath,
hostId,
+ isPanelOpen,
lineRange,
onOpenInEditor,
}: HostScopedFilePreviewTabContentProps) {
@@ -502,7 +508,7 @@ export function HostScopedFilePreviewTabContent({
isFetching,
isLoading,
refetch,
- } = useHostFilePreview(hostId, activePath);
+ } = useHostFilePreview(hostId, activePath, { enabled: isPanelOpen });
return (
({
+ createPreview: vi.fn(),
+ read: vi.fn(),
+}));
+
+vi.mock("@/lib/sdk", () => ({
+ sdk: { files: filesSdk },
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+describe("useHostFilePreview", () => {
+ it("uses a successful preview lease for media without reading or retaining file bytes", async () => {
+ filesSdk.createPreview.mockResolvedValue({
+ baseUrl: "/api/v1/file-previews/lease-1",
+ expiresAtMs: Date.now() + 60_000,
+ });
+ filesSdk.read.mockResolvedValue({
+ path: "/tmp/diagram.png",
+ content: "iVBORw0KGgo=",
+ contentEncoding: "base64",
+ mimeType: "image/png",
+ modifiedAtMs: 1,
+ sha256: "hash",
+ sizeBytes: 8,
+ });
+ const { queryClient, wrapper } = createQueryClientTestHarness();
+ const { result } = renderHook(
+ () => useHostFilePreview("host-1", "/tmp/diagram.png"),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(filesSdk.createPreview).toHaveBeenCalledTimes(1);
+ expect(filesSdk.read).not.toHaveBeenCalled();
+ expect(result.current.data).toEqual({
+ kind: "image",
+ mimeType: "image/png",
+ name: "diagram.png",
+ path: "/tmp/diagram.png",
+ url: "/api/v1/file-previews/lease-1/diagram.png",
+ });
+ expect(
+ queryClient.getQueryCache().find({
+ queryKey: hostFilePreviewQueryKey("host-1", "/tmp/diagram.png"),
+ })?.gcTime,
+ ).toBe(HEAVY_PAYLOAD_GC_TIME_MS);
+ });
+
+ it("keeps HTML source bytes while avoiding a base64 fallback after a lease succeeds", async () => {
+ filesSdk.createPreview.mockResolvedValue({
+ baseUrl: "/api/v1/file-previews/lease-2",
+ expiresAtMs: Date.now() + 60_000,
+ });
+ filesSdk.read.mockResolvedValue({
+ path: "/tmp/report.html",
+ content: "Report
",
+ contentEncoding: "utf8",
+ mimeType: "text/html",
+ modifiedAtMs: 1,
+ sha256: "hash",
+ sizeBytes: 15,
+ });
+ const encodeSpy = vi.spyOn(globalThis, "btoa");
+ const { wrapper } = createQueryClientTestHarness();
+ const { result } = renderHook(
+ () => useHostFilePreview("host-1", "/tmp/report.html"),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(filesSdk.createPreview).toHaveBeenCalledTimes(1);
+ expect(filesSdk.read).toHaveBeenCalledTimes(1);
+ expect(filesSdk.createPreview.mock.invocationCallOrder[0]).toBeLessThan(
+ filesSdk.read.mock.invocationCallOrder[0]!,
+ );
+ expect(encodeSpy).not.toHaveBeenCalled();
+ expect(result.current.data).toMatchObject({
+ kind: "text",
+ content: "Report
",
+ url: "/api/v1/file-previews/lease-2/report.html",
+ });
+ });
+
+ it("keeps ambiguous TypeScript paths on the source-preview path", async () => {
+ filesSdk.createPreview.mockResolvedValue({
+ baseUrl: "/api/v1/file-previews/lease-3",
+ expiresAtMs: Date.now() + 60_000,
+ });
+ filesSdk.read.mockResolvedValue({
+ path: "/tmp/example.ts",
+ content: "export const value = 1;\n",
+ contentEncoding: "utf8",
+ mimeType: "video/mp2t",
+ modifiedAtMs: 1,
+ sha256: "hash",
+ sizeBytes: 24,
+ });
+ const { wrapper } = createQueryClientTestHarness();
+ const { result } = renderHook(
+ () => useHostFilePreview("host-1", "/tmp/example.ts"),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(filesSdk.read).toHaveBeenCalledTimes(1);
+ expect(result.current.data).toMatchObject({
+ kind: "text",
+ content: "export const value = 1;\n",
+ });
+ });
+
+ it("reads and builds a data URL only after preview lease creation fails", async () => {
+ filesSdk.createPreview.mockRejectedValue(new Error("host unavailable"));
+ filesSdk.read.mockResolvedValue({
+ path: "/tmp/diagram.png",
+ content: "iVBORw0KGgo=",
+ contentEncoding: "base64",
+ mimeType: "image/png",
+ modifiedAtMs: 1,
+ sha256: "hash",
+ sizeBytes: 8,
+ });
+ const { wrapper } = createQueryClientTestHarness();
+ const { result } = renderHook(
+ () => useHostFilePreview("host-1", "/tmp/diagram.png"),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(filesSdk.createPreview.mock.invocationCallOrder[0]).toBeLessThan(
+ filesSdk.read.mock.invocationCallOrder[0]!,
+ );
+ expect(result.current.data).toMatchObject({
+ kind: "image",
+ url: "data:image/png;base64,iVBORw0KGgo=",
+ });
+ });
+
+ it("aborts an active read and releases the heavy cache entry when disabled", async () => {
+ let readSignal: AbortSignal | undefined;
+ filesSdk.createPreview.mockResolvedValue({
+ baseUrl: "/api/v1/file-previews/lease-4",
+ expiresAtMs: Date.now() + 60_000,
+ });
+ filesSdk.read.mockImplementation(
+ ({ signal }: { signal: AbortSignal }) =>
+ new Promise((_resolve, reject) => {
+ readSignal = signal;
+ signal.addEventListener("abort", () => reject(signal.reason));
+ }),
+ );
+ const { queryClient, wrapper } = createQueryClientTestHarness();
+ const { rerender } = renderHook(
+ ({ enabled }) =>
+ useHostFilePreview("host-1", "/tmp/example.txt", { enabled }),
+ { initialProps: { enabled: true }, wrapper },
+ );
+
+ await waitFor(() => expect(filesSdk.read).toHaveBeenCalledTimes(1));
+ const activeQuery = queryClient.getQueryCache().find({
+ queryKey: hostFilePreviewQueryKey("host-1", "/tmp/example.txt"),
+ });
+ expect(activeQuery).toBeDefined();
+
+ vi.useFakeTimers();
+ rerender({ enabled: false });
+ expect(readSignal?.aborted).toBe(true);
+ expect(activeQuery?.getObserversCount()).toBe(0);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(HEAVY_PAYLOAD_GC_TIME_MS + 1);
+ });
+ expect(
+ queryClient.getQueryCache().find({
+ queryKey: hostFilePreviewQueryKey("host-1", "/tmp/example.txt"),
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/app/src/hooks/queries/host-file-preview-query.ts b/apps/app/src/hooks/queries/host-file-preview-query.ts
index ff05642aee..79b5883b0a 100644
--- a/apps/app/src/hooks/queries/host-file-preview-query.ts
+++ b/apps/app/src/hooks/queries/host-file-preview-query.ts
@@ -2,10 +2,49 @@ import { useQuery } from "@tanstack/react-query";
import { sdk } from "@/lib/sdk";
import {
buildFilePreview,
+ isHtmlFilePreviewPath,
normalizeFilePreviewMimeType,
type FilePreview,
} from "@/lib/file-preview";
import { hostFilePreviewQueryKey } from "./query-keys";
+import { HEAVY_PAYLOAD_QUERY_POLICY } from "./query-policies";
+
+interface QueryOptions {
+ enabled?: boolean;
+}
+
+interface HostMediaPreviewType {
+ kind: "image" | "video";
+ mimeType: string;
+}
+
+const HOST_MEDIA_PREVIEW_TYPES = new Map([
+ [".avif", { kind: "image", mimeType: "image/avif" }],
+ [".bmp", { kind: "image", mimeType: "image/bmp" }],
+ [".gif", { kind: "image", mimeType: "image/gif" }],
+ [".heic", { kind: "image", mimeType: "image/heic" }],
+ [".heif", { kind: "image", mimeType: "image/heif" }],
+ [".ico", { kind: "image", mimeType: "image/vnd.microsoft.icon" }],
+ [".jpeg", { kind: "image", mimeType: "image/jpeg" }],
+ [".jpg", { kind: "image", mimeType: "image/jpeg" }],
+ [".png", { kind: "image", mimeType: "image/png" }],
+ [".svg", { kind: "image", mimeType: "image/svg+xml" }],
+ [".svgz", { kind: "image", mimeType: "image/svg+xml" }],
+ [".tif", { kind: "image", mimeType: "image/tiff" }],
+ [".tiff", { kind: "image", mimeType: "image/tiff" }],
+ [".webp", { kind: "image", mimeType: "image/webp" }],
+ [".3g2", { kind: "video", mimeType: "video/3gpp2" }],
+ [".3gp", { kind: "video", mimeType: "video/3gpp" }],
+ [".avi", { kind: "video", mimeType: "video/x-msvideo" }],
+ [".m4v", { kind: "video", mimeType: "video/x-m4v" }],
+ [".mov", { kind: "video", mimeType: "video/quicktime" }],
+ [".mp4", { kind: "video", mimeType: "video/mp4" }],
+ [".mpeg", { kind: "video", mimeType: "video/mpeg" }],
+ [".mpg", { kind: "video", mimeType: "video/mpeg" }],
+ [".ogv", { kind: "video", mimeType: "video/ogg" }],
+ [".webm", { kind: "video", mimeType: "video/webm" }],
+ [".wmv", { kind: "video", mimeType: "video/x-ms-wmv" }],
+]);
function decodeBase64Bytes(content: string): Uint8Array {
const binaryContent = atob(content);
@@ -44,35 +83,84 @@ function splitAbsoluteHostFilePath(path: string): {
return { name, rootPath };
}
-export function useHostFilePreview(hostId: string | null, path: string | null) {
- const enabled = hostId !== null && path !== null;
+function getHostMediaPreviewType(name: string): HostMediaPreviewType | null {
+ const extensionIndex = name.lastIndexOf(".");
+ if (extensionIndex <= 0) return null;
+ return (
+ HOST_MEDIA_PREVIEW_TYPES.get(name.slice(extensionIndex).toLowerCase()) ??
+ null
+ );
+}
+
+export function useHostFilePreview(
+ hostId: string | null,
+ path: string | null,
+ options?: QueryOptions,
+) {
+ const enabled =
+ (options?.enabled ?? true) && hostId !== null && path !== null;
+ const activeHostId = enabled ? hostId : null;
+ const activePath = enabled ? path : null;
return useQuery({
- queryKey: hostFilePreviewQueryKey(hostId, path),
+ // Move a retained-but-disabled observer off the heavy payload's key. That
+ // aborts an in-flight read and lets the one-minute GC policy start while
+ // the closed panel body remains mounted.
+ queryKey: hostFilePreviewQueryKey(activeHostId, activePath),
queryFn: async ({ signal }) => {
- if (hostId === null || path === null) {
+ if (activeHostId === null || activePath === null) {
throw new Error("Host file preview target is incomplete");
}
- const response = await sdk.files.read({ hostId, path, signal });
+ const { name, rootPath } = splitAbsoluteHostFilePath(activePath);
+ const previewLease = await sdk.files
+ .createPreview({ hostId: activeHostId, rootPath, signal })
+ .catch(() => null);
+ signal.throwIfAborted();
+ const previewUrl =
+ previewLease === null
+ ? null
+ : `${previewLease.baseUrl}/${encodeURIComponent(name)}`;
+ const mediaPreviewType = getHostMediaPreviewType(name);
+ if (previewUrl !== null && mediaPreviewType !== null) {
+ return { ...mediaPreviewType, name, path: activePath, url: previewUrl };
+ }
+
+ const response = await sdk.files.read({
+ hostId: activeHostId,
+ path: activePath,
+ signal,
+ });
const contentBytes =
response.contentEncoding === "base64"
? decodeBase64Bytes(response.content)
: new TextEncoder().encode(response.content);
const mimeType = normalizeFilePreviewMimeType(response.mimeType ?? null);
+ const preview = buildFilePreview({
+ contentBytes,
+ mimeType,
+ name,
+ path: activePath,
+ url: previewUrl ?? activePath,
+ });
+ if (
+ previewUrl !== null ||
+ (preview.kind !== "image" &&
+ preview.kind !== "video" &&
+ !isHtmlFilePreviewPath(activePath))
+ ) {
+ return preview;
+ }
+
const base64Content =
response.contentEncoding === "base64"
? response.content
: encodeBase64Bytes(contentBytes);
- const { name, rootPath } = splitAbsoluteHostFilePath(path);
- const previewLease = await sdk.files
- .createPreview({ hostId, rootPath, signal })
- .catch(() => null);
- const url =
- previewLease === null
- ? `data:${mimeType};base64,${base64Content}`
- : `${previewLease.baseUrl}/${encodeURIComponent(name)}`;
- return buildFilePreview({ contentBytes, mimeType, name, path, url });
+ return {
+ ...preview,
+ url: `data:${mimeType};base64,${base64Content}`,
+ };
},
enabled,
staleTime: 30_000,
+ ...HEAVY_PAYLOAD_QUERY_POLICY,
});
}