Skip to content
Merged
146 changes: 113 additions & 33 deletions webviews/dev-server/galleryHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
// virtual:cmux-gallery/themes every theme the app ships (Resources/ghostty/themes, Resources/cmux-themes)
// virtual:cmux-gallery/web-theme WebTheme.bootstrapScript, the script the app injects at
// document start in every cmux web view (WebTheme.swift)
// virtual:cmux-gallery/agent-pane.css the agent pane's shipped stylesheet, the files and the
// order scripts/cmux-next/build-agent-pane-web.sh inlines
// virtual:cmux-gallery/agent-pane.css the agent pane's shipped stylesheet: the sheets dev.tsx
// reaches, in the order build-agent-pane-web.sh inlines them
// virtual:cmux-gallery/metrics the chrome metrics (MetricTunables.swift: sidebar width,
// tab strip height, ...) per density, for window mode
// virtual:cmux-gallery/fixtures every shared fixture JSON (schemas/gallery/fixtures.json
Expand All @@ -17,10 +17,12 @@
// virtual:cmux-gallery/revision the checkout's commit (sha, subject, commit time, branch),
// read when the module loads; the live server pushes newer
// ones (galleryLive.ts)
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { Plugin } from "vite-plus";
import { parseGhosttyTheme, type GhosttyTheme } from "../src/gallery/theme/ghostty";

Expand All @@ -32,6 +34,7 @@ export const CMUX_THEMES_DIR = path.join(repoRoot, "Resources/cmux-themes");
const WEB_THEME_SWIFT = path.join(repoRoot, "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift");
const PANE_BUILD_SCRIPT = path.join(repoRoot, "scripts/cmux-next/build-agent-pane-web.sh");
const SESSION = path.join(webviewsRoot, "src/agent-session");
const PANE_TAILWIND = path.join(SESSION, "acpmux/tailwind.css");
const galleryDir = path.join(webviewsRoot, "src/gallery");

const FIXTURE_INDEX = path.join(repoRoot, "schemas/gallery/fixtures.json");
Expand Down Expand Up @@ -117,21 +120,99 @@ export function readWebThemeBootstrap(file = WEB_THEME_SWIFT): string {
}

/**
* The pane's stylesheets in shipped order: desktop.css, the shared stylesheet without its Tailwind
* @import lines, then every `$SRC/...css` file the build script concatenates. (KaTeX's stylesheet
* loads beside it; the shipped copy only inlines its fonts.)
* The pane's stylesheets in shipped order, derived as build-agent-pane-web.sh derives them: every
* sheet dev.tsx (the dev server's entry) reaches, in the order its modules run (bundle.mjs
* --css-inputs-only, cssInImportOrder), with Tailwind's entry last, where the script compiles it.
* No list to parse here: a sheet a component imports lands in the gallery pane as in the app.
* (KaTeX's stylesheet loads beside it; the shipped copy only inlines its fonts.)
*/
export function agentPaneStylesheets(script = PANE_BUILD_SCRIPT): string[] {
const text = fs.readFileSync(script, "utf8");
// `$SRC/...` (the session sources) and `$ROOT/webviews/src/...` (shared ui/ files such as
// ui/popupSurface.css), in the script's order, so the gallery pane matches the shipped one.
const files = [...text.matchAll(/"\$(SRC|ROOT\/webviews\/src)\/([^"$]+\.css)"/g)].map((match) =>
match[1] === "SRC" ? path.join(SESSION, match[2]!) : path.join(webviewsRoot, "src", match[2]!),
);
if (!files.some((file) => file.endsWith("shared/styles.css")))
throw new Error("gallery: build-agent-pane-web.sh no longer names shared/styles.css; update agentPaneStylesheets");
const desktop = path.join(webviewsRoot, "src/pages/shared/desktop.css");
return [desktop, ...files.filter((file) => file !== desktop)];
export function agentPaneStylesheets(): string[] {
return (paneSheets ??= derivePaneSheets());
}

let paneSheets: string[] | undefined;

/** bundle.mjs's arguments: the script's own command, writing the sheet list to `list`. */
function paneSheetsArgs(work: string, list: string): string[] {
return [
"scripts/agent-pane/bundle.mjs",
path.join(SESSION, "acpmux/dev.tsx"),
path.join(SESSION, "acpmux/shiki"),
path.join(work, "order/dev.js"),
"--split",
"--css-inputs",
list,
"--css-inputs-only",
];
}

/** The list bundle.mjs wrote, as absolute paths, Tailwind's entry moved last. */
function readPaneSheets(list: string): string[] {
const sheets = fs
.readFileSync(list, "utf8")
.split("\n")
.filter(Boolean)
.map((sheet) => path.join(webviewsRoot, sheet))
.filter((file) => file !== PANE_TAILWIND);
if (!sheets.some((file) => file.endsWith("shared/styles.css")))
throw new Error("gallery: dev.tsx's sheets leave out shared/styles.css; the pane would render unstyled");
return [...sheets, PANE_TAILWIND];
}

// process.execPath: bun or node, whichever runs the gallery (touched.ts, Vite).
function derivePaneSheets(): string[] {
const work = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-gallery-pane-sheets-"));
try {
const list = path.join(work, "page-sheets");
execFileSync(process.execPath, paneSheetsArgs(work, list), {
cwd: webviewsRoot,
stdio: ["ignore", "ignore", "inherit"],
});
return readPaneSheets(list);
} finally {
fs.rmSync(work, { recursive: true, force: true });
}
}

async function derivePaneSheetsAsync(): Promise<string[]> {
const work = await fs.promises.mkdtemp(path.join(os.tmpdir(), "cmux-gallery-pane-sheets-"));
try {
const list = path.join(work, "page-sheets");
await promisify(execFile)(process.execPath, paneSheetsArgs(work, list), { cwd: webviewsRoot });
return readPaneSheets(list);
} finally {
await fs.promises.rm(work, { recursive: true, force: true });
}
}

let rederiving: Promise<void> | undefined;
let rederiveAgain = false;

/**
* The dev server's answer to a saved source, which may add or drop a stylesheet import: the list
* derived again off the save's path, and `changed` called when it differs. A save while one runs
* runs it once more after (it may have read the file before the save); it stops when no save came.
*/
function rederivePaneSheets(changed: () => void): void {
if (rederiving) {
rederiveAgain = true;
return;
}
rederiving = (async () => {
do {
rederiveAgain = false;
try {
const next = await derivePaneSheetsAsync();
const before = paneSheets;
paneSheets = next;
if (before && before.join("\n") !== next.join("\n")) changed();
} catch (error) {
console.warn(`gallery: the agent pane's sheets were not derived again: ${String(error)}`);
}
} while (rederiveAgain);
})().finally(() => {
rederiving = undefined;
});
}

export type Revision = { sha: string; subject: string; committedAt: number; branch: string };
Expand Down Expand Up @@ -160,24 +241,12 @@ export function readRevision(root = repoRoot): Revision {
}

function agentPaneCSS(): string {
const inlineRelativeImports = (file: string, css: string, seen = new Set<string>()): string =>
css.replace(/^@import\s+["']([^"']+)["'];\s*$/gm, (statement, specifier: string) => {
if (!specifier.startsWith(".")) return statement;
const imported = path.resolve(path.dirname(file), specifier);
if (seen.has(imported)) return "";
seen.add(imported);
if (!fs.existsSync(imported)) return statement;
return inlineRelativeImports(imported, fs.readFileSync(imported, "utf8"), seen);
});

// As the script: a sheet's own @import never loads in the page (the walk already lists an imported
// local sheet before its importer); Tailwind's entry keeps its import and gets the pane's sources.
return agentPaneStylesheets()
.map((file) => {
const css = fs.readFileSync(file, "utf8");
const body = file.endsWith("shared/styles.css")
? css.replace(/^@import .*$/gm, "")
: file.endsWith("acpmux/tailwind.css")
? `${css}\n${tailwindSources()}`
: inlineRelativeImports(file, css);
const body = file === PANE_TAILWIND ? `${css}\n${tailwindSources()}` : css.replace(/^@import .*$/gm, "");
return `/* ${path.relative(webviewsRoot, file)} */\n${body}`;
})
.join("\n");
Expand Down Expand Up @@ -261,7 +330,18 @@ export function galleryModules(): Plugin {
});
},
handleHotUpdate({ file, server, modules }) {
// A save of any pane stylesheet updates the combined sheet in place.
// A saved source may add or drop a stylesheet import: the list is derived again after this
// update, and the combined sheet reloads when it changed. A save of a pane stylesheet updates
// the combined sheet in place.
if (/\.(tsx?|mjs)$/.test(file) && file.startsWith(`${webviewsRoot}/src/`)) {
rederivePaneSheets(() => {
const combined = server.moduleGraph.getModuleById(PANE_CSS_PATH);
if (!combined) return;
server.moduleGraph.invalidateModule(combined);
server.ws.send({ type: "full-reload" });
});
return undefined;
}
if (!agentPaneStylesheets().includes(file)) return undefined;
const combined = server.moduleGraph.getModuleById(PANE_CSS_PATH);
if (!combined) return undefined;
Expand Down
4 changes: 4 additions & 0 deletions webviews/scripts/gallery/touched.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const EVERYTHING = [
/^webviews\/scripts\/gallery\//,
/^webviews\/dev-server\/galleryHost\.ts$/,
/^scripts\/cmux-next\/build-agent-pane-web\.sh$/,
// The pane's sheets and their order (build-agent-pane-web.sh derives the page's <style> from it).
/^webviews\/src\/agent-session\/acpmux\/dev\.tsx$/,
// What derives that list (galleryHost.ts runs bundle.mjs --css-inputs-only with these plugins).
/^webviews\/scripts\/agent-pane\/(bundle|tailwindCompile|jsonParsePlugin|katexStylePlugin)\.mjs$/,
/^webviews\/vite\.config(\.gallery)?\.ts$/,
/^webviews\/package\.json$/,
/^bun\.lock$/,
Expand Down
76 changes: 58 additions & 18 deletions webviews/src/agent-session/acpmux/composer.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,28 @@ const finished = [
summary(9, { status: "completed" }),
];

// Chips a chat kept from a draft (`chat.draftAttachments.get`, keptAttachments.ts). A paste no longer
// makes these (a PDF or an image the engine cannot draw goes to the session as its file path,
// intakeFiles.ts), but a draft kept from before still brings them back.
const TWO_PAGE_PDF = `%PDF-1.4\n1 0 obj << /Type /Page >> endobj\n2 0 obj << /Type /Page >> endobj\n%%EOF`;
const KEPT_PDF = {
id: "kept-pdf",
kind: "document",
name: "design-notes.pdf",
mimeType: "application/pdf",
size: TWO_PAGE_PDF.length,
data: btoa(TWO_PAGE_PDF),
pageCount: 2,
};
const KEPT_BROKEN_IMAGE = {
id: "kept-broken-image",
kind: "image",
name: "image.png",
mimeType: "image/png",
size: 8,
data: btoa(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4)),
};

/// Pastes files into the prompt as the browser does: a paste event whose clipboardData is a real
/// DataTransfer, so the prompt's paste handler can read it (getData) as it reads a person's paste.
function pasteFiles(ctx: PlayContext, files: File[]): void {
Expand Down Expand Up @@ -680,18 +702,19 @@ export default agentPaneEntry({
},
},
"image-failed": {
note: "A pasted image whose bytes cannot be drawn: its chip says Couldn't load image, and × still removes it.",
note: "A kept draft's image whose bytes cannot be drawn: its chip says Couldn't load image and keeps its × (shown on hover).",
snapshot: ((base) => ({ ...base, summary: { ...base.summary!, promptCapabilities: { image: true } } }))(
chat(finished),
),
native: { "chat.draftAttachments.get": [KEPT_BROKEN_IMAGE] },
play: async (ctx) => {
const view = ctx.document.defaultView!;
const file = new view.File([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])], "image.png", {
type: "image/png",
});
pasteFiles(ctx, [file]);
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-attachment:not([aria-busy])"));
await new Promise((resolve) => setTimeout(resolve, 300));
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-attachment-image-failed"));
const chip = ctx.find({ selector: ".acpmux-attachment-image-failed" });
if (chip.querySelector(".acpmux-attachment-failed-text")?.textContent !== "Couldn't load image")
throw new Error("an image that cannot be drawn must say Couldn't load image");
if (!chip.querySelector("button.acpmux-attachment-remove[aria-label]"))
throw new Error("an image that cannot be drawn must keep its remove button");
await ctx.hover({ selector: ".acpmux-attachment-image-failed" });
},
},
// Leo (2026-10-21), as in Claude: a huge paste is a chip with its first words and line count,
Expand Down Expand Up @@ -739,15 +762,14 @@ export default agentPaneEntry({
},
},
"pdf-attached": {
note: "A pasted PDF shows a preview card, its file name, and the detected page count before sending.",
note: "A kept draft's PDF shows a preview card, its file name, and its page count before sending.",
snapshot: chat(finished),
native: { "chat.draftAttachments.get": [KEPT_PDF] },
play: async (ctx) => {
const view = ctx.document.defaultView!;
const pdf = `%PDF-1.4\n1 0 obj << /Type /Page >> endobj\n2 0 obj << /Type /Page >> endobj\n%%EOF`;
const file = new view.File([pdf], "design-notes.pdf", { type: "application/pdf" });
pasteFiles(ctx, [file]);
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-attachment-document"));
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-attachment-document[data-page-count='2']"));
if (ctx.find({ selector: ".acpmux-attachment-document-name" }).textContent !== "design-notes.pdf")
throw new Error("the PDF card must name its file");
await ctx.click({ selector: ".acpmux-attachment-document-open" });
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-pdf-viewer"));
const dialog = ctx.document.querySelector<HTMLElement>(".acpmux-pdf-viewer");
Expand All @@ -757,16 +779,17 @@ export default agentPaneEntry({
},
},
"file-saved-as-path": {
note: "Pasted files no content block takes (a zip, a video) are saved by the chat's host; each chip names the file and says it goes as a file path. A text file beside them is a plain file chip, sent in the prompt's text.",
note: "Pasted files (a text file, a zip, a video) are saved by the chat's host; each chip names the file and says it goes as a file path.",
snapshot: chat(finished),
native: {
"chat.upload": {
uploads: [
{ name: "notes.md", path: "/Users/dev/Library/Application Support/cmux/Chat Uploads/1/0/notes.md" },
{
name: "build-logs.zip",
path: "/Users/dev/Library/Application Support/cmux/Chat Uploads/1/0/build-logs.zip",
path: "/Users/dev/Library/Application Support/cmux/Chat Uploads/1/1/build-logs.zip",
},
{ name: "repro.mov", path: "/Users/dev/Library/Application Support/cmux/Chat Uploads/1/1/repro.mov" },
{ name: "repro.mov", path: "/Users/dev/Library/Application Support/cmux/Chat Uploads/1/2/repro.mov" },
],
},
},
Expand All @@ -783,15 +806,32 @@ export default agentPaneEntry({
const paste = new view.Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files, types: ["Files"] } });
field.dispatchEvent(paste);
await ctx.waitFor(() => ctx.document.querySelectorAll(".acpmux-attachment-link[data-saved]").length === 2);
await ctx.waitFor(() => ctx.document.querySelectorAll(".acpmux-attachment-link[data-saved]").length === 3);
const links = ctx.document.querySelectorAll<HTMLElement>(".acpmux-attachment-link[data-saved]");
const chips = [...links].map((chip) => ({
name: chip.querySelector(".acpmux-attachment-link-name")?.textContent,
note: chip.querySelector(".acpmux-attachment-link-note")?.textContent,
path: chip.title,
}));
for (const name of ["notes.md", "build-logs.zip", "repro.mov"]) {
const chip = chips.find((entry) => entry.name === name);
if (!chip) throw new Error(`no saved chip names ${name}`);
if (chip.note !== "Sent as file path") throw new Error(`${name}'s chip must say it goes as its file path`);
if (!chip.path.endsWith(`/${name}`) || !chip.path.includes("/Chat Uploads/"))
throw new Error(`${name}'s chip must link the copy the host saved, not ${chip.path}`);
}
if (ctx.find({ selector: ".acpmux-md" }).textContent?.includes("Chat Uploads"))
throw new Error("a saved file's path must go as its chip's link, not as prompt text");
},
},
"file-not-attached": {
note: "A pasted file no content block takes, in a chat whose host saves nothing: no chip, and the note says why.",
snapshot: chat(finished),
native: { "chat.upload": { nativeError: { code: "unsupported" } } },
// A host that saves nothing refuses both the staged upload and the older single call.
native: {
"chat.upload.begin": { nativeError: { code: "unsupported" } },
"chat.upload": { nativeError: { code: "unsupported" } },
},
play: async (ctx) => {
const view = ctx.document.defaultView!;
const file = new view.File([new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xff])], "build-logs.zip", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2213,7 +2213,13 @@ export default agentPaneEntry({
if (ctx.document.querySelector(".cv-tools > .cv-tool.is-toggle")) await openFoldedRun(ctx);
const closed = '.cv-tool.is-toggle[aria-expanded="false"]';
await ctx.waitFor(() => ctx.document.querySelectorAll(closed).length === ENVELOPE_TOOLS.length);
for (let opened = 0; opened < ENVELOPE_TOOLS.length; opened++) await ctx.click({ selector: closed });
// Each row centered first: the runner scrolls a target only as far as needed, which parks a row
// at the transcript's foot, under Jump to latest (shown while a run is working), and the click
// lands on the pill.
for (let opened = 0; opened < ENVELOPE_TOOLS.length; opened++) {
ctx.find({ selector: closed }).scrollIntoView({ block: "center" });
await ctx.click({ selector: closed });
}
await ctx.waitFor(() => ctx.document.querySelectorAll(".cv-tool-output-card").length === ENVELOPE_TOOLS.length);
if (!ctx.document.querySelector(".cv-tool-output__markdown h2"))
throw new Error("the MCP text list did not render as Markdown");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ export default agentPaneEntry({
const x = box.left + box.width / 2;
return [box.top + 6, box.bottom - 6].every((y) => menu.contains(ctx.document.elementFromPoint(x, y)));
});
await ctx.hover({ text: "More models" });
// The row itself, by role: the menu's hidden width probe (QuickMenuSpacer) repeats its text
// later in the document, and a text target takes the last match.
await ctx.hover({ role: "menuitem", name: "More models" });
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-quick-more"));
const rows = ctx.document.querySelectorAll(".acpmux-quick-more [role^=menuitem]");
if (!rows[rows.length - 1]?.textContent?.includes("Add harness"))
Expand Down
Loading
Loading