Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1e9253b
test: cover deferred inline diff loading
teamleaderleo Oct 29, 2026
d72fb12
fix: show inline diff placeholders while its renderer loads
teamleaderleo Oct 29, 2026
043c98b
fix: keep diff placeholders visible without card borders
teamleaderleo Oct 29, 2026
523e862
fix: match the measured short diff body height
teamleaderleo Oct 29, 2026
e5aca49
Merge feat-cmux-next into lane/aiforui-inline-diff-loading-20261029
inter-milan-vs-tottenham-spurs Nov 1, 2026
dba3d7b
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
cmux-bot[bot] Nov 1, 2026
06f2d6c
Merge feat-cmux-next into lane/aiforui-inline-diff-loading-20261029
inter-milan-vs-tottenham-spurs Nov 1, 2026
f8c93b8
Merge feat-cmux-next into lane/aiforui-inline-diff-loading-20261029 (…
teamleaderleo Nov 1, 2026
a9c5e84
Merge feat-cmux-next into lane/aiforui-inline-diff-loading-20261029
teamleaderleo Nov 1, 2026
b2e36a8
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
cmux-bot[bot] Nov 1, 2026
761eb1b
fix: inline diff placeholder keeps the rendered diff's height through…
teamleaderleo Nov 1, 2026
a7a177c
Merge remote lane/aiforui-inline-diff-loading-20261029 (bot base update)
teamleaderleo Nov 1, 2026
233d845
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
cmux-bot[bot] Nov 2, 2026
5349a40
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
teamleaderleo Nov 2, 2026
955b8d6
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
cmux-bot[bot] Nov 2, 2026
91106c0
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
cmux-bot[bot] Nov 2, 2026
4367630
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
teamleaderleo Nov 2, 2026
508bb57
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
cmux-bot[bot] Nov 2, 2026
3d2b7a8
test(transcript): opening an edit in a chat that fit moves no row abo…
teamleaderleo Nov 2, 2026
f8400ff
fix(transcript): a fold that makes a fitting chat overflow opens it w…
teamleaderleo Nov 2, 2026
f769f6b
Merge branch 'lane/aiforui-inline-diff-loading-20261029' of github.co…
teamleaderleo Nov 2, 2026
07685a9
Merge branch 'feat-cmux-next' into lane/aiforui-inline-diff-loading-2…
cmux-bot[bot] Nov 2, 2026
0e6bbde
test(transcript-type): scroll to the diagram once the opened tool's f…
teamleaderleo Nov 2, 2026
0e2a2bb
Merge branch 'lane/aiforui-inline-diff-loading-20261029' of github.co…
teamleaderleo Nov 2, 2026
51bdab0
test(transcript-type): wait on the diagram drawing, not a fixed settle
teamleaderleo Nov 2, 2026
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
7 changes: 7 additions & 0 deletions release-notes/next/highlights/inline-diff-loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: Inline changes stay visible while loading
category: fixed
docs: https://cmux.com/docs/agents
---

Opening an edit in a chat now shows line placeholders while its changes load, keeping the filename and preview controls in place.
8 changes: 4 additions & 4 deletions scripts/gallery-matrix/shift-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -1828,10 +1828,10 @@
"agent-pane.transcript#cut-line-open": 0.01286,
"agent-pane.transcript#dense-ended-run": 0,
"agent-pane.transcript#dense-live-run": 0,
"agent-pane.transcript#edit-long-line-code": 0.00251,
"agent-pane.transcript#edit-long-line-code-cut": 0.00416,
"agent-pane.transcript#edit-long-line-prose": 0.00417,
"agent-pane.transcript#edit-preview-open": 0.00497,
"agent-pane.transcript#edit-long-line-code": 0.0125,
"agent-pane.transcript#edit-long-line-code-cut": 0.0156,
"agent-pane.transcript#edit-long-line-prose": 0.015,
"agent-pane.transcript#edit-preview-open": 0.0781,
"agent-pane.transcript#edited-files": 0,
"agent-pane.transcript#failed-send-too-large": 0,
"agent-pane.transcript#failed-sends-grouped": 0,
Expand Down
10 changes: 10 additions & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4365,6 +4365,16 @@ export function VirtualTranscript({
// A tail that fits shows from the top, as before; it opens once it overflows.
endSeek();
setSeeking(false);
} else if (folding) {
// A disclosure the reader clicked in a tail that fit made it overflow: the chat opens where
// the reader is, the clicked line held (above), not at the latest row. Jumping there put the
// rows a frame up before the hold brought them back, or, past the hold, moved the whole
// thread up under the line just clicked (#27650, an edit's diff opened in a short chat).
followTrace.set(node.scrollTop);
follow(node.scrollTop >= latest - 1);
didOpenAtLatest.current = true;
endSeek();
setSeeking(false);
} else {
const top = at < 0 ? latest : Math.min(latest, layout.tops[at] + openAt!.offset);
clearScrollOrigin(node);
Expand Down
39 changes: 30 additions & 9 deletions webviews/src/agent-session/acpmux/conversation/EditDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
// with Show all N lines when it is long (BlockParts.tsx). A prose file (Markdown, text) wraps with a
// hanging indent; code keeps its lines whole, its right edge fading while more lies past it. The
// changes view (changes/EditBlock.tsx) draws the same edits at full size.
import { lazy, Suspense, useContext, useLayoutEffect, useMemo, useState } from "react";
import { lazy, Suspense, useContext, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { editPatch, type TurnFile } from "../diff";
import { copyTextResult } from "./clipboard";
import { Copy } from "./icons";
import { useT } from "../i18n";
import { fileBlockKind, useBlockWrap } from "./blockWrap";
import { PreviewMore, usePreview, useSidewaysRef, WrapButton } from "./BlockParts";
import { FindReveal, foldFind } from "./chatFind";
import { EditDiffLoading, useDiffPainted } from "./EditDiffLoading";

/// The card's lines (EditDiffHunks.tsx, on @pierre/diffs) come in their own chunk, so the page's
/// first script parses without Pierre (bd cx-z9kz0). The header shows at once; the page starts the
Expand Down Expand Up @@ -45,16 +46,26 @@ function useFindInFile(file: TurnFile) {
return found;
}

export function EditDiff({
file,
reveal = 0,
whole = false,
}: {
type EditDiffProps = {
file: TurnFile;
/** Bumped when Find in Chat reveals the call: opens the preview, as the match may be past it. */
reveal?: number;
/** Never cut to a preview (an edit a permission ask is about). */
whole?: boolean;
};

export function EditDiff(props: EditDiffProps) {
return <EditDiffFrame {...props} hunks={EditDiffHunks} />;
}

/** The real card and preview boundary, also used by the gallery with a deferred renderer. */
export function EditDiffFrame({
file,
reveal = 0,
whole = false,
hunks: Hunks,
}: EditDiffProps & {
hunks: ComponentType<{ file: TurnFile; patches: string[]; wrap: boolean }>;
}) {
const t = useT();
const [copied, setCopied] = useState(false);
Expand All @@ -70,6 +81,8 @@ export function EditDiff({
reveal + found,
);
const body = useSidewaysRef<HTMLDivElement>();
const hunks = useRef<HTMLDivElement>(null);
const painted = useDiffPainted(hunks);
return (
<div className="cv-edit-diff" data-wrap={wrap}>
<div className="cv-edit-diff__header">
Expand All @@ -94,9 +107,17 @@ export function EditDiff({
</button>
</div>
<div ref={body} className="cv-edit-diff__body" data-preview={preview.attribute}>
<Suspense fallback={null}>
<EditDiffHunks file={file} patches={patches} wrap={wrap} />
</Suspense>
{/* The placeholder holds the body's height until Pierre has drawn every edit: the renderer's
chunk resolves first, then each host fills a frame or more later, so a Suspense fallback
alone left an empty body for a frame. Both share one grid cell, the lines hidden. */}
<div className="cv-edit-diff__stack" data-painted={painted || undefined}>
<div ref={hunks} className="cv-edit-diff__hunks">
<Suspense fallback={null}>
<Hunks file={file} patches={patches} wrap={wrap} />
</Suspense>
</div>
{painted ? null : <EditDiffLoading file={file} wrap={wrap} />}
</div>
</div>
<PreviewMore preview={preview} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// l10n-allow-file: deterministic inline edit fixtures, never chat content.
import { componentEntry } from "../../../gallery/format";
import type { DiffHunk, TurnFile } from "../diff";
import { EditDiff, EditDiffFrame } from "./EditDiff";

const deferred = new Promise<never>(() => {});
function PendingHunks(): never {
throw deferred;
}

const PROSE_LINE =
"Retries back off with full jitter, so a burst of failures spreads out instead of hammering the upstream again at the same instant.";

function added(count: number, start: number, text: (line: number) => string): DiffHunk {
return {
lines: Array.from({ length: count }, (_, index) => ({
type: "add" as const,
text: text(start + index),
newLine: start + index,
})),
};
}

type Fixture = "code" | "prose" | "hunks";

function fixture(kind: Fixture, lines: number, numbered: boolean): TurnFile {
const path = kind === "prose" ? "docs/retry-policy.md" : "src/retry-policy.ts";
const hunks: DiffHunk[] =
kind === "hunks"
? // Mid-file hunks: Pierre draws a separator before each, for the lines it skips.
[added(3, 40, (line) => `retry(${line});`), added(2, 120, (line) => `retry(${line});`)]
: lines
? [added(lines, 1, (line) => (kind === "prose" ? `${line}. ${PROSE_LINE}` : `retry(${line});`))]
: [];
const count = hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0);
return {
path,
displayPath: path,
additions: count,
deletions: 0,
created: kind !== "hunks",
edits: [{ toolId: "retry-policy", numbered, hunks }],
};
}
type Props = { kind?: Fixture; lines?: number; numbered?: boolean; loaded?: boolean; whole?: boolean };
function InlineDiffLoading({ kind = "code", lines = 3, numbered = true, loaded = false, whole = false }: Props) {
const file = fixture(kind, lines, numbered);
return loaded ? (
<EditDiff file={file} whole={whole} />
) : (
<EditDiffFrame file={file} whole={whole} hunks={PendingHunks} />
);
}
export default componentEntry<Props>({
id: "agent-pane.inline-diff-loading",
title: "Inline edit loading",
area: "Agent pane",
pane: true,
height: 260,
widths: { narrow: 320, normal: 640 },
covers: ["agent-session/acpmux/conversation/EditDiff.tsx", "agent-session/acpmux/conversation/EditDiffLoading.tsx"],
styles: () => Promise.all([import("../styles.css"), import("./conversation.css")]),
load: async () => InlineDiffLoading,
variants: {
short: { props: { lines: 3 }, note: "Deferred renderer with three known lines." },
preview: { props: { lines: 1284 }, note: "Large edit retains the eight-line preview and expansion control." },
fragment: { props: { lines: 3, numbered: false }, note: "Unnumbered edit has no fabricated line-number gutter." },
empty: { props: { lines: 0 }, note: "Known empty edit retains its existing explanation while the chunk loads." },
loaded: { props: { lines: 3, loaded: true }, note: "The same edit after the renderer loads." },
"fragment-loaded": {
props: { lines: 3, numbered: false, loaded: true },
note: "The fragment after the renderer loads.",
},
prose: {
props: { kind: "prose", lines: 5 },
note: "A Markdown edit's long lines wrap under their start while the chunk loads, as tall as the rendered diff.",
},
"prose-loaded": {
props: { kind: "prose", lines: 5, loaded: true },
note: "The wrapped edit after the renderer loads.",
},
hunks: {
props: { kind: "hunks" },
note: "Mid-file hunks keep the room of the separators the rendered diff draws before them.",
},
"hunks-loaded": { props: { kind: "hunks", loaded: true }, note: "The mid-file hunks after the renderer loads." },
},
});
124 changes: 124 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/EditDiffLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useLayoutEffect, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import type { DiffEdit, TurnFile } from "../diff";
import { useT } from "../i18n";

/// Lines drawn as text while the renderer loads; past this an edit's remaining lines are one block
/// of their unwrapped height, so a huge diff paints a bounded number of nodes.
export const LOADING_TEXT_LINES = 400;

/// What the placeholder draws for one edit: its hunks' lines and, before a hunk that skips lines
/// of the file, the separator Pierre draws there (its `collapsedBefore`, @pierre/diffs
/// parsePatchFiles: the hunk's new-file start less the previous hunk's end, from the same ranges
/// `editPatch` writes). Pierre draws no separator after the last hunk of a patch.
type Part = { kind: "sep" } | { kind: "line"; text: string } | { kind: "rest"; lines: number };

function editParts(edit: DiffEdit, budget: number): { parts: Part[]; digits: number } {
const parts: Part[] = [];
let lastEnd = 0;
let maxLine = 0;
let drawn = 0;
let rest = 0;
for (const hunk of edit.hunks) {
const oldLines = hunk.lines.filter((line) => line.type !== "add");
const newLines = hunk.lines.filter((line) => line.type !== "del");
const start = newLines[0]?.newLine ?? Math.max(0, (oldLines[0]?.oldLine ?? 1) - 1);
if (start - 1 - lastEnd > 0) {
if (rest) parts.push({ kind: "rest", lines: rest });
rest = 0;
parts.push({ kind: "sep" });
}
lastEnd = start + newLines.length - 1;
for (const line of hunk.lines) {
maxLine = Math.max(maxLine, line.newLine ?? 0, line.oldLine ?? 0);
if (drawn < budget) {
parts.push({ kind: "line", text: line.text });
drawn++;
} else rest++;
}
}
if (rest) parts.push({ kind: "rest", lines: rest });
return { parts, digits: Math.max(4, String(maxLine).length) };
}

/** No syntax renderer in this dependency graph. While Pierre's chunk loads, each edit draws its
* lines as text in the diff's font, column and wrapping, painted as bars, so the body is as tall
* as the rendered diff and nothing moves when it replaces this (gallery health's layout-shift
* check). A cut preview draws its hidden lines too, so Show all opens it to the rendered diff's
* height while the chunk is still pending. */
export function EditDiffLoading({ file, wrap }: { file: TurnFile; wrap: boolean }) {
const t = useT();
let budget = LOADING_TEXT_LINES;
return (
<section className="cv-edit-diff__loading" aria-label={t("changes.loading")} aria-busy="true">
{file.edits.map((edit, index) => {
if (!edit.hunks.length)
return (
<div key={edit.toolId + index} className="cv-edit-diff__empty">
{t("changes.noLineChanges")}
</div>
);
const { parts, digits } = editParts(edit, Math.max(0, budget));
budget -= parts.filter((part) => part.kind === "line").length;
const children: ReactNode[] = parts.map((part, at) =>
part.kind === "sep" ? (
<div key={at} className="cv-edit-diff__loading-sep" />
) : part.kind === "rest" ? (
<div
key={at}
className="cv-edit-diff__loading-rest"
style={{ "--cv-loading-lines": part.lines } as CSSProperties}
/>
) : (
<div key={at} className="cv-edit-diff__loading-line">
{part.text ? <span>{part.text}</span> : null}
</div>
),
);
return (
<div
key={edit.toolId + index}
className="cv-edit-diff__loading-lines"
data-numbered={edit.numbered}
data-wrap={wrap}
data-find-skip=""
aria-hidden="true"
style={{ "--cv-loading-digits": digits } as CSSProperties}
>
{children}
</div>
);
})}
</section>
);
}

/// Whether the edit's lines under `ref` are drawn: its renderer has mounted something there, and
/// every Pierre host in it has its height (a host is empty for a frame or more after it mounts).
/// Watches only until then: its observers end when it says yes or the card unmounts.
export function useDiffPainted(ref: RefObject<HTMLElement | null>): boolean {
const [painted, setPainted] = useState(false);
useLayoutEffect(() => {
const node = ref.current;
if (!node || painted) return;
const hosts = () => [...node.querySelectorAll<HTMLElement>("diffs-container")];
const drawn = () => node.childElementCount > 0 && hosts().every((host) => host.getBoundingClientRect().height > 0);
if (drawn()) {
setPainted(true);
return;
}
const view = node.ownerDocument.defaultView;
const resize = view?.ResizeObserver ? new view.ResizeObserver(() => drawn() && setPainted(true)) : undefined;
const watch = () => {
if (drawn()) setPainted(true);
else hosts().forEach((host) => resize?.observe(host));
};
const added = new (view?.MutationObserver ?? MutationObserver)(watch);
added.observe(node, { childList: true, subtree: true });
watch();
return () => {
added.disconnect();
resize?.disconnect();
};
}, [ref, painted]);
return painted;
}
Loading
Loading