Compare commits

..
Author SHA1 Message Date
Mohamed Boudra b4c230e73c fix(app): keep linked badges inline in rendered Markdown on web
The library's web renderer marked an image inline only when it was a
direct child of a paragraph with other content. A linked image sits
under a Link node, so badges such as a review bot's
`[![Retrigger](badge.svg)](url) Confidence Score` header rendered as
100%-wide, 320px-tall block images in the PR panel and file preview.

Images nested in links (or emphasis) now count as inline when their
paragraph has other content; a paragraph made only of images or
image-only links still renders them as blocks. Inline images now take
the line height with automatic width instead of a line-height square,
so wide badges and photos keep their aspect ratio instead of squashing.
2026-11-02 05:43:48 +01:00
5 changed files with 147 additions and 44 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts,
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline at line height and keep their aspect ratio. A linked image counts as an image, so review-bot badges such as `[![Retrigger](badge.svg)](url) Confidence` stay inline on web; the library's web renderer only checked direct paragraph children, so it rendered them as full-width block images (local patch in `patches/react-native-enriched-markdown+*.patch`).
## Engine constraints
@@ -110,6 +110,37 @@ describe("shared Markdown source adaptation", () => {
["https://x/q.png", false],
]);
});
it("renders linked images that share a line with text inline", async () => {
// A review bot's summary header: linked badges inside a heading with the score text.
const reviewHeader =
'<h2><a href="https://x/retrigger"><picture><source media="(prefers-color-scheme: dark)" srcset="https://x/r-dark.svg"><img alt="Retrigger" src="https://x/r.svg" align="right"></picture></a>Confidence Score: 5/5</h2>';
const source = [
reviewHeader,
"[![Alone](https://x/alone.png)](https://x)",
"[![a](https://x/a.png)](https://x) [![b](https://x/b.png)](https://x)",
"See [![gh](https://x/gh.png) GitHub](https://x) for more.",
].join("\n\n");
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
markInlineImages(tree);
const images: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Image") images.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
).toEqual([
["https://x/r.svg", true],
["https://x/alone.png", false],
["https://x/a.png", true],
["https://x/b.png", true],
["https://x/gh.png", true],
]);
});
it("escapes spoiler delimiters only in prose", () => {
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
"Show \\|\\|words\\|\\| and `||code||`.",
@@ -14,45 +14,6 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
}
describe("plugin timeline projection", () => {
it.each(["message", "finished", "errored", "permission-required", "closed"] as const)(
"preserves immutable agent-message provenance for %s cards",
(event) => {
const agentMessage = {
event,
sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" },
text: "The original message, including **Markdown**.",
};
const source: StreamItem = {
kind: "tool_call",
id: "message-1",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "delivery-1",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: agentMessage.text, icon: "bot" },
agentMessage,
},
},
};
const inspect: TimelineItemTransform = ({ item }) => {
expect(item.type).toBe("tool_call");
if (item.type !== "tool_call") throw new Error("Expected a tool call");
expect(item.agentMessage).toEqual(agentMessage);
expect(item.agentMessage).not.toBe(agentMessage);
expect(Object.isFrozen(item.agentMessage)).toBe(true);
expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true);
return undefined;
};
expect(projectPluginTimelineItems([source], inspect)).toEqual([source]);
expect(Object.isFrozen(agentMessage.sender)).toBe(false);
},
);
it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = {
@@ -22,14 +22,13 @@ function cloneAndFreeze<T>(value: T): T {
}
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata, agentMessage } = data;
const { callId, name, status, error, detail, metadata } = data;
const base = {
type: "tool_call" as const,
callId,
name,
detail,
...(metadata ? { metadata } : {}),
...(agentMessage ? { agentMessage } : {}),
};
switch (status) {
case "running":
@@ -3521,7 +3521,21 @@ diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMar
diff --git a/node_modules/react-native-enriched-markdown/src/web/styles.ts b/node_modules/react-native-enriched-markdown/src/web/styles.ts
--- a/node_modules/react-native-enriched-markdown/src/web/styles.ts
+++ b/node_modules/react-native-enriched-markdown/src/web/styles.ts
@@ -290,7 +290,7 @@
@@ -260,9 +260,12 @@ function videoStyle(style: MarkdownStyleInternal): CSSProperties {
function inlineImageStyle(style: MarkdownStyleInternal): CSSProperties {
const size = style.inlineImage.size;
+ // Inline images match the line height and keep their aspect ratio, so wide
+ // badges stay legible instead of shrinking into a square.
return {
- width: size,
+ width: 'auto',
height: size,
+ maxWidth: '100%',
verticalAlign: 'middle',
display: 'inline',
};
@@ -290,7 +293,7 @@ function codeStyle(style: MarkdownStyleInternal): CSSProperties {
const code = style.code;
return {
fontFamily: normalizeFontFamily(code.fontFamily),
@@ -3533,7 +3547,21 @@ diff --git a/node_modules/react-native-enriched-markdown/src/web/styles.ts b/nod
diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js
--- a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js
+++ b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js
@@ -271,7 +271,7 @@
@@ -243,9 +243,12 @@ function videoStyle(style) {
}
function inlineImageStyle(style) {
const size = style.inlineImage.size;
+ // Inline images match the line height and keep their aspect ratio, so wide
+ // badges stay legible instead of shrinking into a square.
return {
- width: size,
+ width: 'auto',
height: size,
+ maxWidth: '100%',
verticalAlign: 'middle',
display: 'inline'
};
@@ -271,7 +274,7 @@ function codeStyle(style) {
const code = style.code;
return {
fontFamily: normalizeFontFamily(code.fontFamily),
@@ -3884,3 +3912,87 @@ diff --git a/node_modules/react-native-enriched-markdown/android/generated/jni/r
}
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
diff --git a/node_modules/react-native-enriched-markdown/src/web/utils.ts b/node_modules/react-native-enriched-markdown/src/web/utils.ts
--- a/node_modules/react-native-enriched-markdown/src/web/utils.ts
+++ b/node_modules/react-native-enriched-markdown/src/web/utils.ts
@@ -34,20 +34,32 @@ export function indexTaskItems(node: ASTNode, counter = { value: 0 }): void {
* Stamps Image nodes with `isInline` when they appear inside a paragraph
* that also contains other content (text, links, etc.).
* Matches native behavior: sole-child images are block-level, mixed are inline.
+ * A link wrapping only an image counts as an image, so `[![badge](x)](y) text`
+ * stamps the nested image inline while a lone `[![x](y)](z)` stays a block.
* Mutates the AST in-place (safe: called once on the freshly-parsed result).
*/
export function markInlineImages(node: ASTNode): void {
if (node.type === 'Paragraph' && node.children) {
- const hasNonImageChild = node.children.some(
- (child) => child.type !== 'Image'
- );
- if (hasNonImageChild) {
- for (const child of node.children) {
- if (child.type === 'Image') {
- child.attributes = { ...child.attributes, isInline: true };
- }
- }
+ if (!node.children.every(isImageLike)) {
+ node.children.forEach(stampInlineImages);
}
}
node.children?.forEach(markInlineImages);
}
+
+function isImageLike(node: ASTNode): boolean {
+ if (node.type === 'Image') return true;
+ return (
+ node.type === 'Link' &&
+ node.children?.length === 1 &&
+ isImageLike(node.children[0]!)
+ );
+}
+
+function stampInlineImages(node: ASTNode): void {
+ if (node.type === 'Image') {
+ node.attributes = { ...node.attributes, isInline: true };
+ return;
+ }
+ node.children?.forEach(stampInlineImages);
+}
diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/utils.js b/node_modules/react-native-enriched-markdown/lib/module/web/utils.js
--- a/node_modules/react-native-enriched-markdown/lib/module/web/utils.js
+++ b/node_modules/react-native-enriched-markdown/lib/module/web/utils.js
@@ -40,18 +40,24 @@ export function indexTaskItems(node, counter = {
*/
export function markInlineImages(node) {
if (node.type === 'Paragraph' && node.children) {
- const hasNonImageChild = node.children.some(child => child.type !== 'Image');
- if (hasNonImageChild) {
- for (const child of node.children) {
- if (child.type === 'Image') {
- child.attributes = {
- ...child.attributes,
- isInline: true
- };
- }
- }
+ if (!node.children.every(isImageLike)) {
+ node.children.forEach(stampInlineImages);
}
}
node.children?.forEach(markInlineImages);
}
+function isImageLike(node) {
+ if (node.type === 'Image') return true;
+ return node.type === 'Link' && node.children?.length === 1 && isImageLike(node.children[0]);
+}
+function stampInlineImages(node) {
+ if (node.type === 'Image') {
+ node.attributes = {
+ ...node.attributes,
+ isInline: true
+ };
+ return;
+ }
+ node.children?.forEach(stampInlineImages);
+}
//# sourceMappingURL=utils.js.map
\ No newline at end of file