diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index edca403..4faf550 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -14,6 +14,7 @@ import { cn } from "@browser-skill/ui"; import { RiArrowDownSLine, RiCheckLine, + RiCloseLine, RiCloseCircleLine, RiErrorWarningLine, RiPictureInPicture2Line, @@ -28,6 +29,7 @@ const asIcon = (component: unknown): IconComponent => component as IconComponent const IconStop = asIcon(RiStopCircleLine); const IconPip = asIcon(RiPictureInPicture2Line); const IconDown = asIcon(RiArrowDownSLine); +const IconClose = asIcon(RiCloseLine); const IconWarn = asIcon(RiErrorWarningLine); const IconPin = asIcon(RiPushpinFill); const IconCloseSession = asIcon(RiCloseCircleLine); @@ -320,6 +322,7 @@ export function OverlayBody(props: { now: number; onPopOut?: (() => void) | undefined; onCollapse?: (() => void) | undefined; + onClosePip?: (() => void) | undefined; inPip: boolean; onHeaderPointerDown?: (event: ReactPointerEvent) => void; }) { @@ -333,6 +336,7 @@ export function OverlayBody(props: { now, onPopOut, onCollapse, + onClosePip, inPip, onHeaderPointerDown, } = props; @@ -389,6 +393,16 @@ export function OverlayBody(props: { ) : null} + {onClosePip !== undefined ? ( + + ) : null}
{displayUrl !== undefined ? ( @@ -572,6 +586,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) : undefined } onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined} + onClosePip={pipWindow !== null ? () => pipWindow.close() : undefined} onHeaderPointerDown={pipWindow === null ? beginMove : undefined} /> ); diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 96095d8..a4fea16 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -149,6 +149,7 @@ export function ObservationSidebarTab({ now={now} inPip={pipWindow !== null} onPopOut={pipWindow === null && pipSupported ? () => popOut() : undefined} + onClosePip={pipWindow !== null ? () => pipWindow.close() : undefined} /> ); diff --git a/packages/dsh-plugin-browserskill/src/image.ts b/packages/dsh-plugin-browserskill/src/image.ts index a0b1801..8f5ed5e 100644 --- a/packages/dsh-plugin-browserskill/src/image.ts +++ b/packages/dsh-plugin-browserskill/src/image.ts @@ -42,7 +42,7 @@ interface LlmLike { * @param data - first bytes of the capture. * @returns the detected media type, or undefined when unrecognized. */ -function sniffImageMediaType(data: Uint8Array): string | undefined { +export function sniffImageMediaType(data: Uint8Array): string | undefined { if (data.length >= 4) { // PNG signature: 89 50 4E 47 (0D 0A 1A 0A) if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) { diff --git a/packages/dsh-plugin-browserskill/src/observation.ts b/packages/dsh-plugin-browserskill/src/observation.ts index 8705b24..5c280f2 100644 --- a/packages/dsh-plugin-browserskill/src/observation.ts +++ b/packages/dsh-plugin-browserskill/src/observation.ts @@ -17,6 +17,7 @@ import { readFile, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Context } from "@deepseek-ai/cordis"; +import { sniffImageMediaType } from "./image"; import type { KeyedExecutor } from "./queue"; import { BskError, type BskRunner, parseBskJson, runWithSessionBusyRetry } from "./runner"; import type { SessionRegistry } from "./sessions"; @@ -467,7 +468,7 @@ export class ObservationService { const seq = (this.seqs.get(sessionId) ?? 0) + 1; this.seqs.set(sessionId, seq); const id = `obs-${sessionId}-${seq}`; - this.frames.set(id, { data, mediaType: "image/png" }); + this.frames.set(id, { data, mediaType: sniffImageMediaType(data) ?? "image/png" }); this.hashes.set(sessionId, hash); const ring = this.rings.get(sessionId) ?? []; ring.push(id); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx index 7290296..21cbdbd 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx @@ -298,6 +298,33 @@ describe("ObservationOverlay", () => { expect(close).toHaveBeenCalledTimes(1); }); + it("shows a close button inside the PiP window that closes it", async () => { + const h = makeHarness([BUSY]); + const pipDoc = document.implementation.createHTMLDocument("pip"); + const close = vi.fn(); + const listeners = new Map void)[]>(); + const requestWindow = vi.fn(async () => { + return { + document: pipDoc, + addEventListener: (name: string, fn: () => void) => { + listeners.set(name, [...(listeners.get(name) ?? []), fn]); + }, + close, + } as unknown as Window; + }); + (window as unknown as Record).documentPictureInPicture = { requestWindow }; + render(); + const popout = await screen.findByRole("button", { name: /Pop out/ }); + fireEvent.click(popout); + await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); + const closeBtn = pipDoc.body.querySelector( + 'button[aria-label="Close mini window"]', + ); + expect(closeBtn).not.toBeNull(); + closeBtn!.click(); + expect(close).toHaveBeenCalledTimes(1); + }); + it("renders the strip for two sessions and pins focus on click", async () => { const older: SessionObservation = { sessionId: "s1", diff --git a/packages/dsh-plugin-browserskill/tests/observation.test.ts b/packages/dsh-plugin-browserskill/tests/observation.test.ts index 98182e2..05b2eb2 100644 --- a/packages/dsh-plugin-browserskill/tests/observation.test.ts +++ b/packages/dsh-plugin-browserskill/tests/observation.test.ts @@ -335,6 +335,18 @@ describe("thumbnail cadence", () => { await waitFor(() => service.getState()[0]?.thumbnailAttachmentId === "obs-s1-1"); expect((await service.readThumbnail("obs-s1-1"))?.mediaType).toBe("image/png"); }); + + it("detects JPEG thumbnail bytes instead of hardcoding image/png", async () => { + const scheduler = fakeScheduler(); + const runner = fakeRunner({ + screenshotBytes: () => new Uint8Array([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x01]), + }); + const { service } = setup({ runner, scheduler }); + service.addSession("s1"); + scheduler.runNext(); + await waitFor(() => service.getState()[0]?.thumbnailAttachmentId === "obs-s1-1"); + expect((await service.readThumbnail("obs-s1-1"))?.mediaType).toBe("image/jpeg"); + }); }); describe("observation traffic isolation", () => {