From 09be5bf1894861605480a4b6a3374834a0175df8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:45:53 +0000 Subject: [PATCH 1/4] fix(webviews): six react-apps-check base reds (tables in WebKit, empty rows, stale tests) - markdown tables: a drag across cells in WebKit stayed on its first cell. prosemirror-tables normalizes a text selection whose head lands at the next cell's start to the first cell's text; Chromium waits for the mouseup to write the editor's selection to the page, WebKit writes it at once and its drag stops there. tableTextSelectPlugin drops that normalization while the button is down. - transcript: a row whose renderer draws nothing took its padding again (`article.acpmux-row:empty` stopped matching once every row holds its `.acpmux-row__gap` target, #26704). The rule now matches a row with no child but the gap. - MarkdownMath: the test reads the text without the break points #27277 adds. - bare-ref-repo: another agent's message is one line until opened (#27312); the test opens it before reading its chips. - transcript-send-remount: the fixture's turn ends with its footer, so the reply does not gain its own action row on send (#27313), a redraw the test is not about. - trailing-plates-tool-output: Reduce Motion still runs transitions for 0.01ms; the test reads the actions once their reveal has run. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-ffe9f4bb4afcf68decf6 --- .../acpmux/conversation/MarkdownMath.test.tsx | 4 +- .../acpmux/conversation/conversation.css | 6 +- webviews/src/pages/markdown/tables.ts | 104 +++++++++++++----- webviews/test/bare-ref-repo.test.tsx | 4 + .../trailing-plates-tool-output.test.ts | 3 + .../test/transcript-send-remount.test.tsx | 9 +- 6 files changed, 100 insertions(+), 30 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/MarkdownMath.test.tsx b/webviews/src/agent-session/acpmux/conversation/MarkdownMath.test.tsx index ac632b00ab1b..a20b9057987b 100644 --- a/webviews/src/agent-session/acpmux/conversation/MarkdownMath.test.tsx +++ b/webviews/src/agent-session/acpmux/conversation/MarkdownMath.test.tsx @@ -6,7 +6,9 @@ import { Markdown, parseMarkdown, renderInline } from "./Markdown"; import { MathInline } from "./Math"; import { safeTail } from "./safeTail"; -const html = (source: string) => renderToStaticMarkup({source}); +// Long tokens get break points after their separators (softBreaks.tsx, #27277); these tests +// read the text the reader sees, so the break points are dropped before comparing. +const html = (source: string) => renderToStaticMarkup({source}).replaceAll("", ""); const math = (source: string) => renderInline(source).flatMap((node) => isValidElement<{ tex: string }>(node) && node.type === MathInline ? [node.props.tex] : [], diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 90857b6b089f..616e670a4de7 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -4128,8 +4128,10 @@ a.cv-sha:focus-visible { } /* A row whose renderer draws nothing (a render update that joined an earlier card, a note whose payload never came) takes no room: the 34px floor and the gap under it were a blank band each, - between "Worked for" and the reply. The element selector outranks every row-kind padding. */ -article.acpmux-row:empty { + between "Worked for" and the reply. The element selector outranks every row-kind padding. Every + row holds its gap target (`.acpmux-row__gap`, App.tsx RowFrame), so such a row has no other child + rather than none at all (`:empty` stopped matching with #26704). */ +article.acpmux-row:not(:has(> :not(.acpmux-row__gap))) { min-height: 0; padding-bottom: 0; } diff --git a/webviews/src/pages/markdown/tables.ts b/webviews/src/pages/markdown/tables.ts index df5e099beb13..6edc0b34caf9 100644 --- a/webviews/src/pages/markdown/tables.ts +++ b/webviews/src/pages/markdown/tables.ts @@ -14,8 +14,8 @@ // - Column widths: a handle on each column's right edge; a drag sets the width, a double-click fits // the column to its content. Widths are a view setting kept per file (`TableWidths`), never // written to the file: GFM has no column widths, and a resize must not mark the file edited. -import type { Node as ProseNode, Schema } from "@milkdown/kit/prose/model"; -import { AllSelection, Plugin, TextSelection, type EditorState } from "@milkdown/kit/prose/state"; +import type { Node as ProseNode, ResolvedPos, Schema } from "@milkdown/kit/prose/model"; +import { AllSelection, Plugin, TextSelection, type EditorState, type Selection } from "@milkdown/kit/prose/state"; import { CellSelection, TableMap, @@ -772,6 +772,30 @@ export const tableDragOutPlugin = $prose( }), ); +/** The position before the table cell that holds `$pos`, or -1 outside a cell. */ +function cellBefore($pos: ResolvedPos): number { + for (let depth = $pos.depth; depth > 0; depth--) { + const role = $pos.node(depth).type.spec.tableRole; + if (role === "cell" || role === "header_cell") return $pos.before(depth); + } + return -1; +} + +/** + * Whether `next` is prosemirror-tables' normalization of `selection`, a text selection from one + * cell into another: its from cell's text (a head at the next cell's start) or a caret at its from + * (a selection between two cells' edges). tableEditing's appendTransaction makes it. + */ +function normalizesAcrossCells(selection: Selection, next: Selection): boolean { + if (!(selection instanceof TextSelection) || !(next instanceof TextSelection)) return false; + const from = cellBefore(selection.$from); + if (from < 0 || from === cellBefore(selection.$to)) return false; + return ( + (next.from === selection.$from.start() && next.to === selection.$from.end()) || + (next.empty && next.from === selection.from) + ); +} + /** * Pointer selection in tables is text selection (user 2026-10-28). prosemirror-tables turns a drag * that crosses into another cell, and a Shift-click from a caret into another cell, into a cell @@ -779,30 +803,58 @@ export const tableDragOutPlugin = $prose( * plugin drops that start unless a cell selection already exists (Cmd-A, then Shift-click extends * it), so the browser's own drag selection goes on, and it does the Shift-click itself as a text * selection from the anchor to the click. + * + * While the button is down it also drops prosemirror-tables' normalization of the drag's text + * selection: when the pointer crosses a cell's edge, the head lands at the next cell's start, and + * tableEditing cuts the selection to the first cell's text (or a caret). Chromium waits for the + * mouseup to write the editor's selection back to the page; WebKit, which the app draws in, writes + * it at once, and its drag then stayed on the first cell ("Ada" for a drag from Ada to Navy). */ -export const tableTextSelectPlugin = $prose( - () => - new Plugin({ - filterTransaction(tr, state) { - const anchor = tr.getMeta(tableEditingKey) as number | undefined; - if (typeof anchor !== "number" || anchor < 0) return true; - return !(tr.selection instanceof CellSelection) || state.selection instanceof CellSelection; - }, - props: { - handleDOMEvents: { - mousedown(view, event) { - if (event.button !== 0 || !event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return false; - const { state } = view; - if (state.selection instanceof CellSelection) return false; - if (!(event.target as Element | null)?.closest?.("td, th")) return false; - const hit = view.posAtCoords({ left: event.clientX, top: event.clientY }); - if (!hit) return false; - const selection = TextSelection.between(state.selection.$anchor, state.doc.resolve(hit.pos)); - event.preventDefault(); - if (!selection.eq(state.selection)) view.dispatch(state.tr.setSelection(selection)); - return true; - }, +export const tableTextSelectPlugin = $prose(() => { + // Whether the primary button went down in a cell of this editor and is still down. + let dragging = false; + return new Plugin({ + filterTransaction(tr, state) { + if ( + dragging && + tr.steps.length === 0 && + tr.selectionSet && + tr.getMeta("pointer") !== true && + normalizesAcrossCells(state.selection, tr.selection) + ) + return false; + const anchor = tr.getMeta(tableEditingKey) as number | undefined; + if (typeof anchor !== "number" || anchor < 0) return true; + return !(tr.selection instanceof CellSelection) || state.selection instanceof CellSelection; + }, + props: { + handleDOMEvents: { + mousedown(view, event) { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey) return false; + if (!(event.target as Element | null)?.closest?.("td, th")) return false; + if (!event.shiftKey) { + const window = view.dom.ownerDocument.defaultView; + if (!window) return false; + dragging = true; + const stop = () => { + dragging = false; + window.removeEventListener("mouseup", stop, true); + window.removeEventListener("dragstart", stop, true); + }; + window.addEventListener("mouseup", stop, true); + window.addEventListener("dragstart", stop, true); + return false; + } + const { state } = view; + if (state.selection instanceof CellSelection) return false; + const hit = view.posAtCoords({ left: event.clientX, top: event.clientY }); + if (!hit) return false; + const selection = TextSelection.between(state.selection.$anchor, state.doc.resolve(hit.pos)); + event.preventDefault(); + if (!selection.eq(state.selection)) view.dispatch(state.tr.setSelection(selection)); + return true; }, }, - }), -); + }, + }); +}); diff --git a/webviews/test/bare-ref-repo.test.tsx b/webviews/test/bare-ref-repo.test.tsx index 151272c69c4d..acb696141339 100644 --- a/webviews/test/bare-ref-repo.test.tsx +++ b/webviews/test/bare-ref-repo.test.tsx @@ -13,6 +13,7 @@ import { resetLinkStore } from "../src/agent-session/acpmux/chips/linkStore"; import { resetPullRequestStore } from "../src/agent-session/acpmux/chips/pullRequestStore"; import { resetReferenceExistence } from "../src/agent-session/acpmux/chips/referenceExistence"; import { newReferenceScopeCache, referenceScopes } from "../src/agent-session/acpmux/conversation/bareReferences"; +import { setMessageOpened } from "../src/agent-session/acpmux/conversation/incomingOpened"; import { useDom } from "./support/unknownWrite"; const dom = useDom(); @@ -251,6 +252,9 @@ test("an agent's message: its own address names the repository of its bare #N", const text = `[lane] #24175 merged: ${pr(CMUX, 24175)}. Next #24175 follow-up.`; const raw = `${text}`; const message = row("user", { text: raw, incoming: { kind: "message", message: { name: "lane", text, raw } } }); + // Another agent's message is one line until opened, its whole text (and its chips) in the panel + // (IncomingMessageCard.tsx, #27312): the person opened this one. + setMessageOpened(undefined, message.id, true); const view = await render([message], []); const refs = view.chips().filter(([label]) => label === "#24175"); expect(refs.length).toBeGreaterThan(0); diff --git a/webviews/test/browser/trailing-plates-tool-output.test.ts b/webviews/test/browser/trailing-plates-tool-output.test.ts index 6e626371d384..18a235a024a9 100644 --- a/webviews/test/browser/trailing-plates-tool-output.test.ts +++ b/webviews/test/browser/trailing-plates-tool-output.test.ts @@ -47,6 +47,9 @@ await requireBrowserLane("trailing-plates-tool-output.test.ts", async () => { // Focus is deterministic in headless CI; :focus-within reveals the same actions without // depending on the pointer still being over the output box when styles are sampled. await button.focus(); + // Reduce Motion still runs every transition, for 0.01ms (shared/desktop.css): the reveal's + // opacity reads 0 in the frame it starts. Read the actions once it has run. + await actions.evaluate((node) => Promise.all(node.getAnimations().map((animation) => animation.finished))); const style = await actions.evaluate((node) => { const computed = getComputedStyle(node); return { diff --git a/webviews/test/transcript-send-remount.test.tsx b/webviews/test/transcript-send-remount.test.tsx index b76267ffb4b5..7cbc285d124e 100644 --- a/webviews/test/transcript-send-remount.test.tsx +++ b/webviews/test/transcript-send-remount.test.tsx @@ -79,7 +79,14 @@ test("sending keeps existing transcript rows mounted and does not redraw them", const registry = { user: TrackingRow, assistant: TrackingRow } as never; const root = createRoot(dom.window.document.getElementById("root")!); const onToggleActivity = () => {}; - const existing = [row("prompt-1", "user", "Earlier prompt"), row("reply-1", "assistant", "Earlier reply")]; + // A finished turn ends with its footer, which holds the reply's actions. Without one, the reply + // is the chat's open end, and a prompt sent under it rightly gives it its own action row + // (model.ts hasReplyActions, #27313): a redraw this test does not forbid. + const existing = [ + row("prompt-1", "user", "Earlier prompt"), + row("reply-1", "assistant", "Earlier reply"), + { ...row("summary-1", "turnSummary", ""), text: undefined, status: "completed", durationMs: 4_000 } as AcpmuxRow, + ]; const render = (rows: AcpmuxRow[], sends: number) => act(async () => { root.render( From 56ac5e665395cd47fc1bc906982ce70b1a33ed2d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 01:23:18 +0000 Subject: [PATCH 2/4] fix(webviews): the browser dev host never invents a folder's trust chat-open-long (busy daemon) has been red since #24144. Before the pane's daemon client binds its actions, a trust read reaches the browser dev host, which answered every folder "unknown". That drew a "Trust cmux?" card (73px) over a folder acpmux trusts. Since #24144 scoped trust to its route authority, the client's bind reads the folder again and the card goes away, so the transcript shifts. Before #24144 the wrong card just stayed. The dev host now says it can't answer acp.trust.get (native.unsupported), as it does for other host-only reads. readFolderTrust treats that as "can't say" and draws no card, and the client's read after the bind gives acpmux's real answer. The shipped app's native host is unchanged. Co-Authored-By: Claude Opus 5.5 --- webviews/src/dev-host/host.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webviews/src/dev-host/host.ts b/webviews/src/dev-host/host.ts index b82582f9d86a..56edec3c4da6 100644 --- a/webviews/src/dev-host/host.ts +++ b/webviews/src/dev-host/host.ts @@ -202,8 +202,12 @@ export function devHostReply(host: DevHostParams, request: Request): DevHostRepl case "file.open": case "link.openPath": return openFile(host, request.params); + // The folder's trust is acpmux's answer, which the pane's own daemon client gives once it is + // bound (App.tsx registers acp.trust.get on window.cmuxAcpmuxActions). A read before that + // reaches this host, which can't say: an invented "unknown" drew a Trust card over a folder + // acpmux trusts until the client's read cleared it, shifting the chat (chat-open-long). case "acp.trust.get": - return { ok: true, value: { cwd: request.params?.cwd ?? host.cwd, level: "unknown" } }; + return unsupported(method); case "acp.trust.set": return { ok: true, value: { cwd: request.params?.cwd ?? host.cwd, level: request.params?.level ?? "unknown" } }; case "pane.context": From 11a24242d4144a4ec5ab801931b7b1d99908b1c1 Mon Sep 17 00:00:00 2001 From: ci Date: Fri, 30 Oct 2026 04:34:17 +0000 Subject: [PATCH 3/4] fix(markdown): a table press ends however the button comes up Review (Codex, #27794): the drag flag ended only on a mouseup or dragstart the page saw. A button released over another app never reaches the page, so the flag stayed set, and the filter went on dropping a cross-cell text selection's narrowing (a keyboard caret move out of it). The window listeners also outlived an editor destroyed mid-press. The press now also ends on the window's own blur (not capturing, since the press's own focus change blurs an element), on a key, and on a mousemove with the primary button up (as prosemirror-view's own MouseDown does). The plugin view's destroy removes the listeners, and a new press clears any earlier one first. A browser test can't release the button without delivering a mouseup to the page (Playwright keeps it pressed, and so does prosemirror-view's own MouseDown), so the case isn't simulated. The existing drag, click and Shift-click tests cover the paths that must keep working. Co-Authored-By: Claude Opus 5.5 --- webviews/src/pages/markdown/tables.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/webviews/src/pages/markdown/tables.ts b/webviews/src/pages/markdown/tables.ts index 6edc0b34caf9..98160725193b 100644 --- a/webviews/src/pages/markdown/tables.ts +++ b/webviews/src/pages/markdown/tables.ts @@ -811,9 +811,14 @@ function normalizesAcrossCells(selection: Selection, next: Selection): boolean { * it at once, and its drag then stayed on the first cell ("Ada" for a drag from Ada to Navy). */ export const tableTextSelectPlugin = $prose(() => { - // Whether the primary button went down in a cell of this editor and is still down. + // Whether the primary button went down in a cell of this editor and is still down; `release` + // ends it. A press can end without a mouseup reaching the page (released over another app or + // after a switch away), so blur, a key, and a move with the button up end it too, as does the + // editor's destroy: a stuck flag would drop a keyboard caret move out of a cross-cell selection. let dragging = false; + let release: (() => void) | undefined; return new Plugin({ + view: () => ({ destroy: () => release?.() }), filterTransaction(tr, state) { if ( dragging && @@ -835,14 +840,25 @@ export const tableTextSelectPlugin = $prose(() => { if (!event.shiftKey) { const window = view.dom.ownerDocument.defaultView; if (!window) return false; + release?.(); dragging = true; + const ends = ["mouseup", "dragstart", "keydown"] as const; + const moved = (move: MouseEvent) => { + if ((move.buttons & 1) === 0) stop(); + }; const stop = () => { dragging = false; - window.removeEventListener("mouseup", stop, true); - window.removeEventListener("dragstart", stop, true); + release = undefined; + for (const type of ends) window.removeEventListener(type, stop, true); + window.removeEventListener("mousemove", moved, true); + window.removeEventListener("blur", stop); }; - window.addEventListener("mouseup", stop, true); - window.addEventListener("dragstart", stop, true); + for (const type of ends) window.addEventListener(type, stop, true); + window.addEventListener("mousemove", moved, true); + // Not capturing: the press itself moves focus into the editor, and that element's blur + // reaches a capturing window listener. Only the window's own blur ends the press. + window.addEventListener("blur", stop); + release = stop; return false; } const { state } = view; From 2788068d87ea3a4c497a5139205df936cede2617 Mon Sep 17 00:00:00 2001 From: ci Date: Fri, 30 Oct 2026 04:55:28 +0000 Subject: [PATCH 4/4] fix(markdown): a key pressed mid-drag does not end a table press Review (Codex, delta at 11a24242): Shift or Cmd pressed during a cross-cell drag leaves the button down, but the capturing keydown listener ended the press, so WebKit's drag went back to sticking on the first cell. The window's blur and a mousemove with the primary button up still end a press whose mouseup never reached the page. Co-Authored-By: Claude Opus 5.5 --- webviews/src/pages/markdown/tables.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/webviews/src/pages/markdown/tables.ts b/webviews/src/pages/markdown/tables.ts index 98160725193b..4b398b23b886 100644 --- a/webviews/src/pages/markdown/tables.ts +++ b/webviews/src/pages/markdown/tables.ts @@ -813,8 +813,9 @@ function normalizesAcrossCells(selection: Selection, next: Selection): boolean { export const tableTextSelectPlugin = $prose(() => { // Whether the primary button went down in a cell of this editor and is still down; `release` // ends it. A press can end without a mouseup reaching the page (released over another app or - // after a switch away), so blur, a key, and a move with the button up end it too, as does the - // editor's destroy: a stuck flag would drop a keyboard caret move out of a cross-cell selection. + // after a switch away), so the window's blur and a move with the button up end it too, as does + // the editor's destroy: a stuck flag would drop a keyboard caret move out of a cross-cell + // selection. A key does not: Shift or Cmd pressed mid-drag leaves the button down. let dragging = false; let release: (() => void) | undefined; return new Plugin({ @@ -842,7 +843,7 @@ export const tableTextSelectPlugin = $prose(() => { if (!window) return false; release?.(); dragging = true; - const ends = ["mouseup", "dragstart", "keydown"] as const; + const ends = ["mouseup", "dragstart"] as const; const moved = (move: MouseEvent) => { if ((move.buttons & 1) === 0) stop(); };