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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { Markdown, parseMarkdown, renderInline } from "./Markdown";
import { MathInline } from "./Math";
import { safeTail } from "./safeTail";

const html = (source: string) => renderToStaticMarkup(<Markdown plainCode>{source}</Markdown>);
// Long tokens get <wbr> 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(<Markdown plainCode>{source}</Markdown>).replaceAll("<wbr/>", "");
const math = (source: string) =>
renderInline(source).flatMap((node) =>
isValidElement<{ tex: string }>(node) && node.type === MathInline ? [node.props.tex] : [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 5 additions & 1 deletion webviews/src/dev-host/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
121 changes: 95 additions & 26 deletions webviews/src/pages/markdown/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -772,37 +772,106 @@ 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
* selection; both start with a transaction that sets `tableEditingKey` to the anchor cell. This
* 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; `release`
// ends it. A press can end without a mouseup reaching the page (released over another app or
// 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({
view: () => ({ destroy: () => release?.() }),
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;
release?.();
dragging = true;
const ends = ["mouseup", "dragstart"] as const;
const moved = (move: MouseEvent) => {
if ((move.buttons & 1) === 0) stop();
};
const stop = () => {
dragging = false;
release = undefined;
for (const type of ends) window.removeEventListener(type, stop, true);
window.removeEventListener("mousemove", moved, true);
window.removeEventListener("blur", stop);
};
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;
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;
},
},
}),
);
},
});
});
4 changes: 4 additions & 0 deletions webviews/test/bare-ref-repo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 = `<agent-message from="lane">${text}</agent-message>`;
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);
Expand Down
3 changes: 3 additions & 0 deletions webviews/test/browser/trailing-plates-tool-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion webviews/test/transcript-send-remount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading