Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/web/src/components/preview/PreviewView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ describe("PreviewView navigation", () => {
mocks.closePictureInPicture.mockClear();
mocks.pickElement.mockReset();
mocks.capturePreviewAnnotationScreenshot.mockReset();
mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "none" });
mocks.capturePreviewAnnotationScreenshot.mockReturnValue({ status: "none" });
mocks.addPreviewAnnotation.mockClear();
vi.mocked(toastManager.add).mockClear();
mocks.addImage.mockClear();
Expand Down Expand Up @@ -605,7 +605,7 @@ describe("PreviewView navigation", () => {
};
const onSendAnnotation = vi.fn();
mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" });
mocks.capturePreviewAnnotationScreenshot.mockReturnValue({ status: "failed" });

renderToStaticMarkup(
<PreviewView
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/PreviewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ export function PreviewView({
// instead of holding the composer for an attachment that never lands.
// The stored copy drops the screenshot on failure, otherwise the prompt
// would tell the agent a crop is attached when none was sent.
const capture = await capturePreviewAnnotationScreenshot(picked);
const capture = capturePreviewAnnotationScreenshot(picked);
// Main reports a crop that failed or timed out on its side; the local
// conversion can fail too. Either way the user should hear about it.
const cropDropped = screenshotFailed || capture.status === "failed";
Expand Down
45 changes: 25 additions & 20 deletions apps/web/src/lib/previewAnnotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,33 +42,38 @@ const annotation: PreviewAnnotationPayload = {

describe("preview annotation capture", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it("returns the crop when the fetch resolves", async () => {
vi.stubGlobal("fetch", async () => new Response(new Blob(["png"], { type: "image/png" })));
const capture = await capturePreviewAnnotationScreenshot(annotation);
it("decodes the screenshot when desktop CSP blocks data URL fetches", async () => {
vi.stubGlobal("fetch", () => {
throw new TypeError("Refused to connect because it violates Content Security Policy");
});
const capture = capturePreviewAnnotationScreenshot(annotation);
expect(capture.status).toBe("captured");
if (capture.status !== "captured") throw new Error("Screenshot was dropped");
expect(capture.file.name).toBe("preview-annotation-annotation_1.png");
expect(capture.file.type).toBe("image/png");
expect(new Uint8Array(await capture.file.arrayBuffer())).toEqual(new Uint8Array([0]));
});

it("reports none when the annotation carries no crop", async () => {
const capture = await capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null });
expect(capture).toEqual({ status: "none" });
});

it("fails instead of hanging when the crop never arrives", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", () => new Promise<Response>(() => {}));
const capturePromise = capturePreviewAnnotationScreenshot(annotation, 1_000);
await vi.advanceTimersByTimeAsync(1_000);
expect(await capturePromise).toEqual({ status: "failed" });
it("reports none when the annotation carries no crop", () => {
expect(capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null })).toEqual({
status: "none",
});
});

it("fails when the crop fetch throws", async () => {
vi.stubGlobal("fetch", async () => {
throw new Error("data url unreadable");
});
expect(await capturePreviewAnnotationScreenshot(annotation)).toEqual({ status: "failed" });
it.each([
"data:image/png;base64,not!base64",
"data:image/png;base64,",
"data:image/png,not-base64",
"https://example.com/screenshot.png",
])("reports a failed conversion for an invalid screenshot: %s", (dataUrl) => {
expect(
capturePreviewAnnotationScreenshot({
...annotation,
screenshot: { ...annotation.screenshot!, dataUrl },
}),
).toEqual({ status: "failed" });
});
});
45 changes: 12 additions & 33 deletions apps/web/src/lib/previewAnnotation.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,28 @@
import type { PreviewAnnotationPayload } from "@t3tools/contracts";

async function previewAnnotationScreenshotFile(
annotation: PreviewAnnotationPayload,
): Promise<File | null> {
if (!annotation.screenshot) return null;
const response = await fetch(annotation.screenshot.dataUrl);
const blob = await response.blob();
return new File([blob], `preview-annotation-${annotation.id}.png`, {
type: blob.type || "image/png",
});
}

/** Upper bound on turning a picked element's crop into a composer attachment. */
const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000;
import { dataUrlToFile } from "./imageCompression";

export type PreviewAnnotationCapture =
/** The crop is ready to attach. */
| { readonly status: "captured"; readonly file: File }
/** The pick carried no crop, which is normal for comment-only annotations. */
| { readonly status: "none" }
/** The crop stalled or threw. Send the annotation without it. */
/** The crop could not be decoded. Send the annotation without it. */
| { readonly status: "failed" };

/**
* Bounded wrapper around `previewAnnotationScreenshotFile`. The picker holds the
* composer while this runs, so it must always settle: a stalled crop resolves as
* `failed` instead of leaving the caller waiting.
*/
export async function capturePreviewAnnotationScreenshot(
/** Decode locally because the desktop CSP does not allow fetching data URLs. */
export function capturePreviewAnnotationScreenshot(
annotation: PreviewAnnotationPayload,
timeoutMs: number = PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS,
): Promise<PreviewAnnotationCapture> {
): PreviewAnnotationCapture {
if (!annotation.screenshot) return { status: "none" };
let timer: ReturnType<typeof setTimeout> | undefined;
const { dataUrl } = annotation.screenshot;
const match = /^data:(image\/[^;,]+);base64,.+$/s.exec(dataUrl);
if (!match?.[1]) return { status: "failed" };
try {
const file = await Promise.race([
previewAnnotationScreenshotFile(annotation),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
}),
]);
return file ? { status: "captured", file } : { status: "failed" };
return {
status: "captured",
file: dataUrlToFile(dataUrl, `preview-annotation-${annotation.id}.png`, match[1]),
};
} catch {
return { status: "failed" };
} finally {
clearTimeout(timer);
}
}
Loading