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
10 changes: 10 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ Read what changed with enough surrounding code to judge it.
Large diffs use bounded paging. Captured historical source and the current
workspace remain distinct.

### Review media

Inspect QA captures and recordings supplied by the launching agent with
`--media`. Media shows only those files by default, and stays hidden when none
were supplied. Changed PNGs retain their before/after view in Changes.

To add changed PNGs to Media, use `--media-include-changed-images` or
`.diffcompass.json` → `media.includeChangedImages: true`. The extra images load
only when viewed, and the choice survives resume. See [review media options](usage.md#review-media).

### Symbol Peek

Follow a definition or usage without losing your place.
Expand Down
24 changes: 24 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ For a stacked branch:
git config branch.my-feature.reviewTourBase parent-feature
```

## Review media

Pass each QA capture or recording with `--media PATH` (PNG, JPEG, GIF, WebP or
MP4). Media shows only those session-owned files by default; its count excludes
images from the diff. With no supplied media, the tab is hidden. Changed PNGs
remain available in Changes with their captured before/after comparison.

To include changed PNGs in Media as well, add `--media-include-changed-images`
to a branch or `diffcompass pr` launch, or set this repository default:

```json
{ "media": { "includeChangedImages": true } }
```

Each available side contributes one item (two for a modified PNG, one for an
added or deleted PNG). These optional images load when scrolled into view or
explicitly opened, rather than during page startup. Supplied `--media` files
keep their existing import and loading behavior.

The choice is saved with the session. On resume, an explicit CLI flag wins over
the saved choice; the saved choice wins over repository configuration. Older
sessions without a saved choice use the repository default, which is false
unless opted in.

## Repository defaults

Add `.diffcompass.json` at the repository root:
Expand Down
82 changes: 56 additions & 26 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ const state = {
: "github",
commentQuoteHighlightRequestId: 0,
screenshotUrls: new Map(),
screenshotLoads: new Map(),
screenshotRenderId: 0,
changedImageObserver: null,
diffImageUrls: new Map(),
diffImageRequestId: 0,
markdownPreviewPaths: new Set(),
Expand Down Expand Up @@ -4376,6 +4379,7 @@ function renderActivities() {
}

function changedImageMedia() {
if (state.session?.mediaIncludeChangedImages !== true) return [];
const revision = state.session?.revision ?? 0;
return (state.session?.comparison?.files ?? []).filter(isPngDiff).flatMap((file) => [
file.oldPath ? { id: `diff:${revision}:${file.path}:old`, path: file.path, side: "old", name: `${file.path} · Before` } : null,
Expand Down Expand Up @@ -4841,7 +4845,7 @@ function addDryRunScenario() {
}

function availableTabs() {
return ["briefing", "diff", "archify", "dry-runs", "findings", "activity", "conversations", "screenshots", ...(state.session?.pullRequest ? ["pull-request"] : [])];
return ["briefing", "diff", "archify", "dry-runs", "findings", "activity", "conversations", ...((state.session?.screenshots?.length || changedImageMedia().length) ? ["screenshots"] : []), ...(state.session?.pullRequest ? ["pull-request"] : [])];
}

const MAP_VIEWS = ["archify", "call-flows", "sequence", "adaptive", "structural-blast-radius"];
Expand Down Expand Up @@ -4887,6 +4891,7 @@ function renderMapsNavigation() {
}

function renderActiveTab() {
if (state.activeTab === "screenshots" && !availableTabs().includes("screenshots")) state.activeTab = "diff";
if (state.activeTab !== "diff") state.briefingSourceReturn = false;
for (const tabName of ["briefing", "diff", "call-flows", "structural-blast-radius", "archify", "sequence", "dry-runs", "adaptive", "findings", "activity", "conversations", "screenshots", "pull-request"]) {
const active = state.activeTab === tabName;
Expand All @@ -4904,55 +4909,80 @@ function renderActiveTab() {
}

async function renderScreenshots() {
const requestId = ++state.screenshotRenderId;
state.changedImageObserver?.disconnect();
state.changedImageObserver = null;
const screenshots = state.session?.screenshots ?? [];
const changedImages = changedImageMedia();
const mediaCount = screenshots.length + changedImages.length;
elements["screenshots-tab-count"].textContent = String(mediaCount);
elements["screenshots-tab-count"].classList.toggle("hidden", mediaCount === 0);
if (mediaCount === 0) {
elements.screenshots.innerHTML = '<p class="empty compact media-empty">No media is available for this review.</p>';
return;
}
elements["screenshots-tab"].classList.toggle("hidden", mediaCount === 0);
const visibleIds = new Set([...screenshots.map((shot) => shot.id), ...changedImages.map((image) => image.id)]);
for (const [id, url] of state.screenshotUrls) {
if (!visibleIds.has(id)) {
URL.revokeObjectURL(url);
state.screenshotUrls.delete(id);
}
}
if (mediaCount === 0) {
elements.screenshots.innerHTML = '<p class="empty compact media-empty">No media is available for this review.</p>';
return;
}
async function mediaUrl(id, path) {
if (state.screenshotUrls.has(id)) return state.screenshotUrls.get(id);
let pending = state.screenshotLoads.get(id);
if (!pending) {
pending = apiBlob(path).finally(() => state.screenshotLoads.delete(id));
state.screenshotLoads.set(id, pending);
}
const blob = await pending;
if (requestId !== state.screenshotRenderId) return;
if (!state.screenshotUrls.has(id)) state.screenshotUrls.set(id, URL.createObjectURL(blob));
return state.screenshotUrls.get(id);
}
elements.screenshots.innerHTML = [
...screenshots.map((shot) => `<figure class="screenshot-card" data-screenshot-id="${escapeHtml(shot.id)}"><div class="screenshot-loading">Loading media…</div><figcaption><strong>${escapeHtml(shot.name)}</strong><span>${Math.max(1, Math.round(shot.size / 1024)).toLocaleString()} KiB</span></figcaption></figure>`),
...changedImages.map((image) => `<figure class="screenshot-card changed-image-media" data-changed-image-id="${escapeHtml(image.id)}"><div class="screenshot-loading">Loading changed image…</div><figcaption><strong>${escapeHtml(image.name)}</strong><span>Changed PNG</span></figcaption></figure>`),
...changedImages.map((image) => `<figure class="screenshot-card changed-image-media" data-changed-image-id="${escapeHtml(image.id)}"><div class="screenshot-loading"><button type="button" class="text-button" aria-label="${escapeHtml(`View ${image.name}`)}">View image</button></div><figcaption><strong>${escapeHtml(image.name)}</strong><span>Changed PNG</span></figcaption></figure>`),
].join("");
await Promise.all([...screenshots.map(async (shot) => {
async function loadChangedImage(image, card) {
if (requestId !== state.screenshotRenderId || !card.isConnected || card.dataset.loading) return;
card.dataset.loading = "true";
state.changedImageObserver?.unobserve(card);
card.querySelector(".screenshot-loading").textContent = "Loading changed image…";
try {
let url = state.screenshotUrls.get(shot.id);
if (!url) {
url = URL.createObjectURL(await apiBlob(`/api/screenshots/${encodeURIComponent(shot.id)}`));
state.screenshotUrls.set(shot.id, url);
}
const url = await mediaUrl(image.id, `/api/diff/image?${new URLSearchParams({ path: image.path, side: image.side })}`);
if (!url || requestId !== state.screenshotRenderId || !card.isConnected) return;
card.querySelector(".screenshot-loading").outerHTML = `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="Open full-size image"><img src="${escapeHtml(url)}" alt="${escapeHtml(image.name)}" /></a>`;
} catch (error) {
if (requestId === state.screenshotRenderId && card.isConnected) card.querySelector(".screenshot-loading").textContent = error.message;
}
}
const imagesById = new Map(changedImages.map(image => [image.id, image]));
if (changedImages.length && typeof IntersectionObserver !== "undefined") {
state.changedImageObserver = new IntersectionObserver(entries => {
if (state.activeTab !== "screenshots" || requestId !== state.screenshotRenderId) return;
for (const entry of entries) if (entry.isIntersecting) void loadChangedImage(imagesById.get(entry.target.dataset.changedImageId), entry.target);
});
}
for (const card of elements.screenshots.querySelectorAll("[data-changed-image-id]")) {
card.querySelector("button").onclick = () => void loadChangedImage(imagesById.get(card.dataset.changedImageId), card);
state.changedImageObserver?.observe(card);
}
await Promise.all(screenshots.map(async (shot) => {
try {
const url = await mediaUrl(shot.id, `/api/screenshots/${encodeURIComponent(shot.id)}`);
if (!url || requestId !== state.screenshotRenderId) return;
const card = elements.screenshots.querySelector(`[data-screenshot-id="${CSS.escape(shot.id)}"]`);
if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").outerHTML = shot.mimeType === "video/mp4"
? `<video src="${escapeHtml(url)}" controls preload="metadata" aria-label="${escapeHtml(shot.name)}"></video>`
: `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="Open full-size image"><img src="${escapeHtml(url)}" alt="${escapeHtml(shot.name)}" /></a>`;
} catch (error) {
if (requestId !== state.screenshotRenderId) return;
const card = elements.screenshots.querySelector(`[data-screenshot-id="${CSS.escape(shot.id)}"]`);
if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").textContent = error.message;
}
}), ...changedImages.map(async (image) => {
try {
let url = state.screenshotUrls.get(image.id);
if (!url) {
url = URL.createObjectURL(await apiBlob(`/api/diff/image?${new URLSearchParams({ path: image.path, side: image.side })}`));
state.screenshotUrls.set(image.id, url);
}
const card = elements.screenshots.querySelector(`[data-changed-image-id="${CSS.escape(image.id)}"]`);
if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").outerHTML = `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="Open full-size image"><img src="${escapeHtml(url)}" alt="${escapeHtml(image.name)}" /></a>`;
} catch (error) {
const card = elements.screenshots.querySelector(`[data-changed-image-id="${CSS.escape(image.id)}"]`);
if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").textContent = error.message;
}
})]);
}));
}

let briefingReader;
Expand Down
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<button id="findings-tab" class="tab" data-tab="findings" role="tab" aria-selected="false" aria-controls="findings-view" tabindex="-1">Findings <span id="findings-tab-count" class="tab-count hidden">0</span></button>
<button id="activity-tab" class="tab" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-view" tabindex="-1">Activity <span id="activity-tab-count" class="tab-count hidden">0</span></button>
<button id="conversations-tab" class="tab" data-tab="conversations" role="tab" aria-selected="false" aria-controls="conversations-view" tabindex="-1">Conversations</button>
<button id="screenshots-tab" class="tab" data-tab="screenshots" role="tab" aria-selected="false" aria-controls="screenshots-view" tabindex="-1">Media <span id="screenshots-tab-count" class="tab-count hidden">0</span></button>
<button id="screenshots-tab" class="tab hidden" data-tab="screenshots" role="tab" aria-selected="false" aria-controls="screenshots-view" tabindex="-1">Media <span id="screenshots-tab-count" class="tab-count hidden">0</span></button>
<button id="pull-request-tab" class="tab hidden" data-tab="pull-request" role="tab" aria-selected="false" aria-controls="pull-request-view" tabindex="-1">Pull request</button>
</div>
<div class="actions">
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-monaco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ try {
}
const groups = {
briefing: { samplePr: false, scripts: ["briefing-entry", "briefing-source", "briefing", "briefing-code", "briefing-diagrams"] },
local: { samplePr: false, scripts: ["monaco-foundation", "monaco-scroll", "monaco-click-focus", "file-header", "monaco-edits", "monaco-edits-syntax", "monaco-themes", "renderer-preference", "monaco-gutter", "monaco-wrapping", "markdown-preview", "monaco-semantic", "monaco-mount", "monaco-paged-draft"] },
local: { samplePr: false, scripts: ["monaco-foundation", "monaco-scroll", "monaco-click-focus", "media", "file-header", "monaco-edits", "monaco-edits-syntax", "monaco-themes", "renderer-preference", "monaco-gutter", "monaco-wrapping", "markdown-preview", "monaco-semantic", "monaco-mount", "monaco-paged-draft"] },
navigation: { samplePr: true, scripts: ["monaco-phase", "monaco-phase-scroll", "monaco-upward-tour", "monaco-reviewed-scroll", "monaco-sticky", "monaco-file-explorer"] },
discussions: { samplePr: true, scripts: ["review-pane", "renderer-comments", "monaco-context", "monaco-edits", "monaco-edits-syntax", "monaco-discussions"] },
before: { samplePr: true, beforeTour: true, scripts: ["monaco-before-tour"] },
Expand Down
80 changes: 80 additions & 0 deletions scripts/media-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
const [playwrightPath, url] = process.argv.slice(2);
const { chromium } = await import(pathToFileURL(playwrightPath).href);
const browser = await chromium.launch({ channel: process.env.CI ? undefined : "chrome", headless: true });
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
const changedCount = 60;
try {
for (const [curatedCount, includeChanged] of [[2, false], [0, false], [2, true], [0, true]]) {
const page = await browser.newPage({ viewport: { width: 1400, height: 850 }, reducedMotion: "reduce" });
const requested = new Set(), errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.route("**/api/update", route => route.fulfill({ json: { updateAvailable: false } }));
await page.route("**/api/session", async route => {
const response = await route.fetch();
const session = await response.json();
const image = session.comparison.files.find(file => file.path === "screens/receipt.png");
assert.ok(image, "Fixture retains a real before/after PNG");
session.comparison.files.push(...Array.from({ length: changedCount - 1 }, (_, index) => {
const path = `screens/reference-${index}.png`;
return { ...image, path, oldPath: path, newPath: path };
}));
session.screenshots = Array.from({ length: curatedCount }, (_, index) => ({ id: `qa-${index}`, name: `QA ${index}.png`, mimeType: "image/png", size: png.length, fileName: `qa-${index}.png` }));
session.mediaIncludeChangedImages = includeChanged;
await route.fulfill({ json: session });
});
await page.route("**/api/screenshots/*", route => route.fulfill({ body: png, contentType: "image/png" }));
await page.route("**/api/diff/image?*", async route => {
const request = new URL(route.request().url());
if (!request.searchParams.get("path").startsWith("screens/reference-")) return route.continue();
requested.add(`${request.searchParams.get("path")}:${request.searchParams.get("side")}`);
await route.fulfill({ body: png, contentType: "image/png" });
});
try {
await page.goto(url);
await page.waitForFunction(() => document.getElementById("diff-renderer")?.disabled === false);
const expected = curatedCount + (includeChanged ? 2 * changedCount : 0);
await page.waitForFunction(expected => document.getElementById("screenshots-tab-count").textContent === String(expected), expected);
await page.waitForTimeout(150);
assert.equal(requested.size, 0, "A hidden Media tab must not fetch changed images");
assert.equal(await page.locator("#screenshots-tab").isVisible(), expected > 0);
if (!expected) {
const tabs = await page.locator('.tab:not(.hidden)').evaluateAll(nodes => nodes.map(node => node.id));
assert.ok(!tabs.includes("screenshots-tab"), "Changed PNGs alone must not expose Media by default");
} else {
await page.locator("#screenshots-tab").click();
assert.equal(await page.locator("#screenshots .screenshot-card").count(), expected);
await page.waitForFunction(count => document.querySelectorAll("#screenshots [data-screenshot-id] img").length === count, curatedCount);
assert.equal(await page.locator("#screenshots [data-screenshot-id] img").count(), curatedCount);
if (!includeChanged) {
assert.equal(requested.size, 0, "Default Media must not fetch screenshot-test references");
assert.equal(await page.locator("#screenshots .changed-image-media").count(), 0);
} else {
const last = page.locator("#screenshots .changed-image-media").last();
assert.equal(await last.locator("img").count(), 0, "Offscreen cards remain unloaded");
// Explicit activation also works without observer visibility (e.g. assistive UI).
await last.locator("button").evaluate(button => button.click());
await last.locator("img").waitFor({ state: "attached" });
assert.ok(requested.has("screens/reference-58.png:new"));
const middle = page.locator("#screenshots .changed-image-media").nth(60);
assert.equal(await middle.locator("img").count(), 0);
await middle.scrollIntoViewIfNeeded();
await middle.locator("img").waitFor({ state: "visible" });
assert.ok(requested.size < changedCount, `Scrolling must not fetch the entire gallery (${requested.size} requests)`);
}
}
// The original PNG still opens in Changes with both captured versions.
await page.locator("#diff-tab").click();
await page.evaluate(() => diffCompassProductionHost.reveal("screens/receipt.png", "new", 1));
const comparison = page.locator('.diff-file[data-path="screens/receipt.png"] .image-diff');
await comparison.locator(".old img").waitFor({ state: "visible" });
await comparison.locator(".new img").waitFor({ state: "visible" });
assert.deepEqual(errors, []);
console.log(`PASS Media ${expected}: ${curatedCount} curated files, ${changedCount} changed PNGs, opt-in ${includeChanged}; lazy requests and Changes comparison`);
} finally {
await page.unrouteAll({ behavior: "wait" });
await page.close();
}
}
} finally { await browser.close(); }
Loading
Loading