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
15 changes: 9 additions & 6 deletions webviews/src/agent-session/acpmux/chips/BareReferenceChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,24 @@ import { usePullRequestsExist } from "./referenceExistence";
const address = (repository: string, number: number) => `https://github.com/${repository}/pull/${number}`;
const NO_URLS: readonly string[] = [];

/// `href` is the folder's (or the transcript's) address for the number: rule d's. Without one, the
/// number is linked only when the transcript names its repository.
/// `href` is the folder's (or the transcript's) address for the number: rule d's. Without one, or
/// when the number is out of every candidate's range (rule 4), the number is linked only when the
/// transcript names its repository.
export function BareReferenceChip({ number, href, text }: { number: number; href?: string; text: string }) {
const scope = useContext(ReferenceScopeContext);
const fallback = href && /^https:\/\/github\.com\/([^/]+\/[^/]+)\//.exec(href)?.[1];
const named = namedRepository(number, scope);
const candidates = !named && fallback ? candidateRepositories(fallback, scope) : [];
// Only a number with more than one candidate is asked about; one candidate is rule d's anyway.
const candidates = !named && fallback ? candidateRepositories(fallback, scope, number) : [];
// Rule d stands for the folder's repository alone, so one candidate is asked about only when the
// folder's is out of range (rule 4) and the cache must say the chat's repository has N.
const folderFirst = Boolean(fallback) && candidates[0] === fallback;
const existing = usePullRequestsExist(
candidates.length > 1 ? candidates.map((repository) => address(repository, number)) : NO_URLS,
candidates.length > (folderFirst ? 1 : 0) ? candidates.map((repository) => address(repository, number)) : NO_URLS,
);
const choice = named
? { repository: named, alternatives: [] }
: candidates.length
? chooseRepository(candidates, existing)
? chooseRepository(candidates, existing, fallback || undefined)
: undefined;
const ambiguous = Boolean(choice?.alternatives.length);
if (!choice) return text;
Expand Down
19 changes: 17 additions & 2 deletions webviews/src/agent-session/acpmux/conversation/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ export type InlineOptions = {
githubRepository?: string;
/** Link GitHub references in prose; labels of an outer link disable this. */
linkGithubReferences?: boolean;
/** Link a bare `#N` too (default). Off in a quote: quoted words are someone else's, whose `#1`
* is rarely this repository's pull request (Leo 2026-10-29: "these auto render to the pr
* instead of just #1 #2..."). `owner/repo#N` and addresses still link there. */
linkBareReferences?: boolean;
/** The chats the open chat started, whose names draw as chips (started/startedSessions.ts). */
started?: StartedMentions;
/** How a commit SHA draws: a GitHub chip (replies), or quiet monospace text that links to its
Expand Down Expand Up @@ -432,6 +436,7 @@ function linkedGithubText(
repository?: string,
enabled = true,
quietCommits = false,
bare = true,
): ReactNode[] {
if (!enabled) return linkedText(text, key);
// A pull request address, so the chip opens the pull request card (an issue redirects there);
Expand All @@ -445,7 +450,7 @@ function linkedGithubText(
const spans = commits.length || issues.some((issue) => issue.bare) ? textLinks(text) : [];
const outside = (ref: { start: number; end: number }) =>
!spans.some((span) => ref.start < span.end && span.start < ref.end);
const refs = [...issues.filter((issue) => !issue.bare || outside(issue)), ...commits.filter(outside)]
const refs = [...issues.filter((issue) => !issue.bare || (bare && outside(issue))), ...commits.filter(outside)]
.sort((a, b) => a.start - b.start)
.filter((ref, index, all) => index === 0 || ref.start >= all[index - 1]!.end);
if (!refs.length) return linkedText(text, key);
Expand Down Expand Up @@ -525,6 +530,7 @@ function proseText(text: string, key: string, opts: InlineOptions): ReactNode[]
opts.githubRepository,
opts.linkGithubReferences !== false,
opts.commits === "quiet",
opts.linkBareReferences !== false,
),
current,
)
Expand Down Expand Up @@ -826,7 +832,7 @@ function Block({
case "blockquote":
return (
<blockquote className={`cv-quote${motion}`}>
<Blocks blocks={block.children} opts={opts} depth={depth} plain={code === "plain"} />
<Blocks blocks={block.children} opts={quoted(opts)} depth={depth} plain={code === "plain"} />
</blockquote>
);
case "list": {
Expand Down Expand Up @@ -894,6 +900,15 @@ function ItemThumbs({ text, opts, streaming }: { text: string; opts: InlineOptio

/// Nested blocks (a quote's, a list item's). `plain` keeps the outer Markdown's plain fences
/// (`plainCode`) inside them too: no diagram or render card at any depth.
/// A quote's options: the same object for the same `opts`, so its memoized blocks stay drawn.
const quotedOptions = new WeakMap<InlineOptions, InlineOptions>();
function quoted(opts: InlineOptions): InlineOptions {
if (opts.linkBareReferences === false) return opts;
let kept = quotedOptions.get(opts);
if (!kept) quotedOptions.set(opts, (kept = { ...opts, linkBareReferences: false }));
return kept;
}

function Blocks({
blocks,
opts,
Expand Down
50 changes: 39 additions & 11 deletions webviews/src/agent-session/acpmux/conversation/bareReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
// d. else the folder's repository (or the transcript's, transcriptRepositories.ts).
// 3. A number that is a pull request in two or more candidates names its repository on the chip
// (`leo-workspace#843`).
// 4. Rules c and d only take a candidate whose numbers the chat named are no longer than N: a cmux
// chat that named #27262 means no pull request by "#1 / #2" (Leo 2026-10-29: "these auto
// render to the pr instead of just #1 #2..."). With no candidate left, N is text.
import { createContext } from "react";
import { githubReferences, isGithubOwner } from "../../../githubReferences";
import type { AcpmuxRow } from "../model";
Expand All @@ -26,6 +29,8 @@ export type ReferenceEvidence = {
mentions: ReadonlyMap<number, readonly Mention[]>;
/** Rule c's candidates after the folder's: the repositories the chat linked, latest first. */
linked: readonly string[];
/** Rule 4: the smallest number the chat named in each repository, by its lowercase name. */
smallest: ReadonlyMap<string, number>;
};

/** A turn's evidence: the transcript's, its own (rule a), and where it starts. */
Expand Down Expand Up @@ -88,6 +93,7 @@ function rowNamed(row: Pick<AcpmuxRow, "kind" | "text" | "items">): readonly Nam
type Reading = {
mentions: Map<number, readonly Mention[]>;
repos: Map<string, Mention>;
smallest: Map<string, number>;
turns: Map<string, { at: number; named: Map<number, string> }>;
turn: string;
};
Expand All @@ -114,13 +120,25 @@ export const newReferenceScopeCache = (): ReferenceScopeCache => ({
scopes: new Map(),
});

const empty = (): Reading => ({ mentions: new Map(), repos: new Map(), turns: new Map(), turn: "" });
const empty = (): Reading => ({
mentions: new Map(),
repos: new Map(),
smallest: new Map(),
turns: new Map(),
turn: "",
});

const copy = (reading: Reading): Reading => {
const turns = new Map(reading.turns);
const open = turns.get(reading.turn);
if (open) turns.set(reading.turn, { at: open.at, named: new Map(open.named) });
return { mentions: new Map(reading.mentions), repos: new Map(reading.repos), turns, turn: reading.turn };
return {
mentions: new Map(reading.mentions),
repos: new Map(reading.repos),
smallest: new Map(reading.smallest),
turns,
turn: reading.turn,
};
};

function read(reading: Reading, row: Pick<AcpmuxRow, "id" | "kind" | "text" | "items">, at: number) {
Expand All @@ -135,6 +153,7 @@ function read(reading: Reading, row: Pick<AcpmuxRow, "id" | "kind" | "text" | "i
reading.repos.delete(key);
reading.repos.set(key, { repo: spelled, at });
if (number === undefined) continue;
if (!(number >= (reading.smallest.get(key) ?? Infinity))) reading.smallest.set(key, number);
reading.mentions.set(number, [...(reading.mentions.get(number) ?? []), { at, repo: spelled }]);
if (!reading.turns.has(reading.turn)) reading.turns.set(reading.turn, { at: 0, named: new Map() });
reading.turns.get(reading.turn)!.named.set(number, spelled);
Expand Down Expand Up @@ -175,7 +194,7 @@ export function referenceScopes(
const evidenceKey = `${cache.generation}:${cache.prefixLength}:${tail}`;
if (cache.evidence?.key !== evidenceKey) {
const linked = [...whole.repos.values()].map(({ repo }) => repo).reverse();
cache.evidence = { key: evidenceKey, value: { mentions: whole.mentions, linked } };
cache.evidence = { key: evidenceKey, value: { mentions: whole.mentions, linked, smallest: whole.smallest } };
cache.scopes.clear();
}
const evidence = cache.evidence.value;
Expand Down Expand Up @@ -217,24 +236,33 @@ export function namedRepository(number: number, scope: ReferenceScope | null): s
return before;
}

/** Rule c's candidates: `fallback` (rule d's) first, then the chat's repositories, latest first. */
export function candidateRepositories(fallback: string, scope: ReferenceScope | null): string[] {
/** Rule 4: whether `number` is as long as every number the chat named in `repo`; a repository
* the chat named no number in has no range, so any number is in it. */
export function inNamedRange(number: number, repo: string, scope: ReferenceScope | null): boolean {
const smallest = scope?.evidence.smallest.get(repo.toLowerCase());
return smallest === undefined || String(number).length >= String(smallest).length;
}

/** Rule c's candidates: `fallback` (rule d's) first, then the chat's repositories, latest first,
* each only if `number` is in its range (rule 4). */
export function candidateRepositories(fallback: string, scope: ReferenceScope | null, number: number): string[] {
const out = [fallback];
for (const repo of scope?.evidence.linked ?? []) {
if (out.length >= MAX_CANDIDATES) break;
if (!out.some((kept) => kept.toLowerCase() === repo.toLowerCase())) out.push(repo);
}
return out;
return out.filter((repo) => inNamedRange(number, repo, scope));
}

/** Rules c and d: the first candidate with pull request N, and the others that have it too;
* `existing` is undefined (or has none) until the cache answers, and then rule d stands. */
* `existing` is undefined (or has none) until the cache answers, and then rule d stands when
* `fallback` (the folder's repository) is still a candidate; without it, N is text. */
export function chooseRepository(
candidates: readonly string[],
existing: readonly boolean[] | undefined,
): { repository: string; alternatives: string[] } {
fallback = candidates[0],
): { repository: string; alternatives: string[] } | undefined {
const found = existing ? candidates.filter((_, index) => existing[index]) : [];
return found.length
? { repository: found[0]!, alternatives: found.slice(1) }
: { repository: candidates[0]!, alternatives: [] };
if (found.length) return { repository: found[0]!, alternatives: found.slice(1) };
return fallback !== undefined && candidates[0] === fallback ? { repository: fallback, alternatives: [] } : undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,19 @@ export default agentPaneEntry({
]),
play: hoverReference(843, ".cv-link-preview.acpmux-pr-card"),
},
"github-references-quoted-numbers": {
note: 'Leo 2026-10-29: "these auto render to the pr instead of just #1 #2...". The reply quotes his "what is #1 / #2": in the quote and in prose they stay text (the chat named #27262, so a one-digit number is no cmux pull request); #27262 and manaflow-ai/cmux#27262 stay chips.',
ready: { githubRepository: "manaflow-ai/cmux" },
native: { "git.githubRepository": { repository: "manaflow-ai/cmux" } },
snapshot: chat([
user("Is https://github.com/manaflow-ai/cmux/pull/27262 green? Also what is #1 / #2", 3),
assistant(
"> what is #1 / #2\n\nThose are the two options I listed: #1 keeps the dock, #2 drops it. #27262 (manaflow-ai/cmux#27262) is green.",
2,
),
summary(2, { status: "completed", durationMs: 9_000 }),
]),
},
tasks: {
note: "Checked, unchecked, nested and wrapping task-list items.",
snapshot: chat([user("Review the release checklist", 4), assistant(TASKS_REPLY, 3)]),
Expand Down
42 changes: 42 additions & 0 deletions webviews/test/bare-ref-repo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,45 @@ test("a streaming reply reads only its own row again; an older row that changed
const again = referenceScopes([prompt, done, assistant("#9")], cache);
expect(again(prompt.id).evidence.mentions.get(9)).toEqual([{ at: 1, repo: "x/y" }]);
});

// Leo 2026-10-29: "these auto render to the pr instead of just #1 #2...". The agent quoted his
// "what is #1 / #2", and each bare number drew as a chip for cmux's (real, unrelated) pull
// requests #1 and #2.
test("Leo's #1 / #2: a quote's bare numbers stay text; explicit references stay chips", async () => {
const view = await render(
[
user("what is #1 / #2"),
// The turn names cmux#2 itself (rule a), so only the quote keeps the quoted #2 text.
assistant(`> what is #1 / #2\n\nThe options I listed, not pull requests (${CMUX}#2 is unrelated).`),
],
[pr(CMUX, 1), pr(CMUX, 2)],
{ folder: CMUX },
);
expect(view.chips()).toEqual([[`${CMUX}#2`, pr(CMUX, 2)]]);
expect(view.element.querySelector(".cv-quote")?.textContent).toContain("what is #1 / #2");
});

test("Leo's #1 / #2: a bare number far below the repository's named numbers stays text", async () => {
const view = await render(
[
user(`Land ${pr(CMUX, 27262)}, then what is #1 / #2`),
assistant('You asked "what is #1 / #2": #1 is the sidebar fix, #2 the composer one. #27262 is green.'),
],
[pr(CMUX, 1), pr(CMUX, 2), pr(CMUX, 27262)],
{ folder: CMUX },
);
expect(view.chips().filter(([label]) => label?.startsWith("#"))).toEqual([["#27262", pr(CMUX, 27262)]]);
});

test("rule 4: with the folder's repository out of range, only the cache's answer links a number", async () => {
const view = await render(
[user(`Land ${pr(CMUX, 27262)} and look at ${pr("o/r", 3)}`), assistant("#5 is o/r's; #6 is nobody's.")],
[pr("o/r", 5)],
{ folder: CMUX },
);
expect(view.chips().filter(([label]) => label?.startsWith("#"))).toEqual([["#5", pr("o/r", 5)]]);
const asked = inspected(view.calls);
expect(asked).toContain(pr("o/r", 6));
// The folder's repository is out of range for both, so neither is asked about there.
expect(asked.filter((url) => url === pr(CMUX, 5) || url === pr(CMUX, 6))).toEqual([]);
});
Loading