Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2411,6 +2411,124 @@ it("keeps a workspace PDF open across URL renewals and reloads it when the file
URL.revokeObjectURL(edited);
}
});
it("keeps a workspace image and its zoom across agent commands and reloads it when the file changes", async () => {
assetUrlState.mockReset();
const { WorkspaceImagePreview } = await import("./FilePreviewPanel");
const original =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"/>';
const edited =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="20"/>';
const preview = (mutation: string) => (
<WorkspaceImagePreview
environmentId={EnvironmentId.make("test")}
threadRef={{ environmentId: EnvironmentId.make("test"), threadId: ThreadId.make("test") }}
draft={false}
workspaceRoot="/repo"
absolutePath="/repo/screenshot.svg"
alt="screenshot.svg"
workspaceMutationId={mutation}
/>
);
// The fragment keeps the revision query out of the data URL's payload.
assetUrlState.mockReturnValue({
_tag: "Success",
url: `${original}#initial`,
expiresAt: 0,
fileRevision: "one",
});
await using screen = await render(preview("first"));
await vi.waitFor(() => expect(screen.container.querySelector("img")).not.toBeNull());
const opened = screen.container.querySelector("img");
const zoomLabel = () =>
[...screen.container.querySelectorAll("span")].find((span) => /^\d+%$/.test(span.textContent))
?.textContent;
await expect.element(screen.getByRole("button", { name: "Zoom in" })).toBeEnabled();
const fitted = zoomLabel();
await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
await vi.waitFor(() => expect(zoomLabel()).not.toBe(fitted));
const zoomed = zoomLabel();

// An agent command moves the workspace revision and renews the URL, but the file is unchanged.
assetUrlState.mockReturnValue({
_tag: "Success",
url: `${original}#renewed`,
expiresAt: 0,
fileRevision: "one",
});
await screen.rerender(preview("second"));
expect(assetUrlState).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "second");
expect(screen.container.querySelector("img")).toBe(opened);
expect(zoomLabel()).toBe(zoomed);

assetUrlState.mockReturnValue({
_tag: "Success",
url: `${edited}#initial`,
expiresAt: 0,
fileRevision: "two",
});
await screen.rerender(preview("third"));
await vi.waitFor(() => {
const reloaded = screen.container.querySelector("img");
expect(reloaded).not.toBe(opened);
expect(reloaded?.getAttribute("src")?.startsWith(edited)).toBe(true);
});
});
it("retries a failed workspace image when its URL is renewed for the same file", async () => {
assetUrlState.mockReset();
const { WorkspaceImagePreview } = await import("./FilePreviewPanel");
const preview = (mutation: string) => (
<WorkspaceImagePreview
environmentId={EnvironmentId.make("test")}
threadRef={{ environmentId: EnvironmentId.make("test"), threadId: ThreadId.make("test") }}
draft={false}
workspaceRoot="/repo"
absolutePath="/repo/screenshot.svg"
alt="screenshot.svg"
workspaceMutationId={mutation}
/>
);
assetUrlState.mockReturnValue({
_tag: "Success",
url: "data:image/svg+xml,broken",
expiresAt: 0,
fileRevision: "one",
});
await using screen = await render(preview("first"));
await expect.element(screen.getByText("Unable to load image.")).toBeVisible();

assetUrlState.mockReturnValue({
_tag: "Success",
url: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="100"/>#renewed',
expiresAt: 0,
fileRevision: "one",
});
await screen.rerender(preview("second"));
await expect.element(screen.getByRole("button", { name: "Zoom in" })).toBeEnabled();
expect(screen.container.textContent).not.toContain("Unable to load image.");
const zoomLabel = () =>
[...screen.container.querySelectorAll("span")].find((span) => /^\d+%$/.test(span.textContent))
?.textContent;
// The label must follow the live viewport, so the resize observer has to watch the new one.
const expectedFit = () => {
const viewport = screen.container.querySelector("img")?.closest("button")
?.parentElement?.parentElement;
if (!viewport) return undefined;
const fit = Math.min(1, (viewport.clientWidth - 32) / 4000, (viewport.clientHeight - 32) / 100);
return `${Math.round(fit * 100)}%`;
};
Object.assign(screen.container.style, {
display: "flex",
flexDirection: "column",
height: "400px",
});
for (const width of ["600px", "300px"]) {
screen.container.style.width = width;
await vi.waitFor(() => {
expect(zoomLabel()).not.toBe("0%");
expect(zoomLabel()).toBe(expectedFit());
});
}
});
function planningPdf(
titles = ["Quarterly outlook", "Delivery milestones", "Budget and risks"],
firstHeight = 140,
Expand Down
23 changes: 13 additions & 10 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ type FilePostRender = <LAnnotation>(
phase: PostRenderPhase,
) => void;

function WorkspaceImagePreview(props: {
export function WorkspaceImagePreview(props: {
readonly environmentId: EnvironmentId;
readonly threadRef: ScopedThreadRef;
readonly draft: boolean;
Expand All @@ -175,21 +175,22 @@ function WorkspaceImagePreview(props: {
readonly alt: string;
readonly workspaceMutationId: string | null;
}) {
// Media files report their revision, so an unchanged image keeps its element and zoom
// when the workspace revision moves; older servers fall back to the workspace revision.
const resource = workspaceAssetResource({
kind:
mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined
? "workspace-file"
: "media-file",
kind: "media-file",
threadRef: props.threadRef,
draft: props.draft,
workspaceRoot: props.workspaceRoot,
absolutePath: props.absolutePath,
});
const assetUrl = useAssetUrlState(props.environmentId, resource);
const assetUrl = useAssetUrlState(props.environmentId, resource, props.workspaceMutationId);
const fileRevision = assetUrl._tag === "Success" ? assetUrl.fileRevision : undefined;
const revision = fileRevision ?? props.workspaceMutationId;
const revisionSuffix =
props.workspaceMutationId === null
revision === null
? ""
: `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
: `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}${fileRevision !== undefined ? "file" : "workspace"}-revision=${encodeURIComponent(revision)}`;
const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;
const actionsSource: MediaActionSource = {
kind: "image",
Expand All @@ -209,10 +210,12 @@ function WorkspaceImagePreview(props: {
);
}

return assetUrl._tag === "Success" && imageUrl !== null ? (
return assetUrl._tag === "Success" &&
imageUrl !== null &&
!(fileRevision === undefined && assetUrl.waiting) ? (
<MediaActions source={actionsSource}>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<PreviewImage key={imageUrl} src={imageUrl} name={props.alt} />
<PreviewImage key={`${props.absolutePath}:${revision}`} src={imageUrl} name={props.alt} />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</div>
</MediaActions>
) : (
Expand Down
23 changes: 12 additions & 11 deletions apps/web/src/components/files/PreviewImageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, ScanIcon } from "lucide-react";
import { ExpandedImageDialog } from "~/components/chat/ExpandedImageDialog";
import { observeResize } from "~/lib/observeResize";
Expand All @@ -11,20 +11,21 @@ export default function PreviewImageContent(props: {
readonly mimeType?: string;
readonly onError?: () => void;
}) {
const [failed, setFailed] = useState(false);
// A renewed URL for the same file retries a failed load without resetting zoom.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const [natural, setNatural] = useState({ width: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [zoom, setZoom] = useState<number | null>(null);
const ref = useRef<HTMLDivElement>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the image viewport before paint and observe resizes to keep fit-to-view scaling accurate.
useLayoutEffect(() => {
const node = ref.current;
if (!node) return;
const update = () => setViewport({ width: node.clientWidth, height: node.clientHeight });
if (!viewportElement) return;
const update = () =>
setViewport({ width: viewportElement.clientWidth, height: viewportElement.clientHeight });
update();
return observeResize(node, update);
}, []);
return observeResize(viewportElement, update);
}, [viewportElement]);
const fit = natural.width
? Math.min(
1,
Expand All @@ -34,7 +35,7 @@ export default function PreviewImageContent(props: {
: 1;
const scale = zoom ?? fit;
const changeZoom = (factor: number) => setZoom(Math.max(0.05, Math.min(8, scale * factor)));
if (failed) return <FileSurfaceFailure message="Unable to load image." />;
if (failedSrc === props.src) return <FileSurfaceFailure message="Unable to load image." />;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return (
<>
<div className="flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-1 text-xs text-muted-foreground">
Expand Down Expand Up @@ -75,7 +76,7 @@ export default function PreviewImageContent(props: {
</FileSurfaceAction>
</div>
</div>
<div ref={ref} className="min-h-0 flex-1 overflow-auto">
<div ref={setViewportElement} className="min-h-0 flex-1 overflow-auto">
<div
className="flex min-h-full min-w-full items-center justify-center p-4"
style={{
Expand Down Expand Up @@ -107,7 +108,7 @@ export default function PreviewImageContent(props: {
})
}
onError={() => {
setFailed(true);
setFailedSrc(props.src);
props.onError?.();
}}
/>
Expand Down
Loading