Repository navigation
fix(mobile): chat folds stay closed until tapped, as on web and desktop #28072
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
maria-rcks
merged 5 commits into
pingdotgg:main
from
Umais-Adeed:fix/mobile-chat-details-folds
Nov 1, 2026
+281
−24
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
375d24c
fix(mobile): chat folds stay closed until tapped, as on web and desktop
Umais-Adeed 043c2ec
fix(mobile): folds ignore tags in code and keep artifact cards inside
Umais-Adeed 5e98dc7
Merge branch 'main' into fix/mobile-chat-details-folds
Umais-Adeed b0d2719
fix(mobile): an open fold closes at a closing tag that ends a paragraph
Umais-Adeed c5b7b9c
fix(mobile): fold open state reads only the open attribute
Umais-Adeed File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { splitMarkdownDetails } from "./markdownDetails"; | ||
|
|
||
| describe("splitMarkdownDetails", () => { | ||
| // regression: #28059 mobile chat showed every <details> fold open | ||
| it("splits a top-level fold into its summary and hidden body", () => { | ||
| const markdown = | ||
| "Short answer.\n\n<details>\n<summary>Long details</summary>\n\nA long paragraph.\n\n</details>\n\nAfter."; | ||
| expect(splitMarkdownDetails(markdown)).toEqual([ | ||
| { kind: "markdown", markdown: "Short answer.\n\n", sourceOffset: 0 }, | ||
| { | ||
| kind: "details", | ||
| summary: "Long details", | ||
| body: "\n\nA long paragraph.\n\n", | ||
| open: false, | ||
| sourceOffset: 15, | ||
| }, | ||
| { kind: "markdown", markdown: "\n\nAfter.", sourceOffset: 87 }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("keeps nested folds in the body and honours the open attribute", () => { | ||
| const markdown = | ||
| "<details open>\n<summary>Outer & <code>more</code></summary>\n\n<details>\n<summary>Inner</summary>\n\nx\n\n</details>\n\n</details>"; | ||
| const [fold] = splitMarkdownDetails(markdown); | ||
| expect(fold).toMatchObject({ kind: "details", summary: "Outer & more", open: true }); | ||
| expect(fold?.kind === "details" && splitMarkdownDetails(fold.body)).toMatchObject([ | ||
| { kind: "markdown" }, | ||
| { kind: "details", summary: "Inner", body: "\n\nx\n\n" }, | ||
| { kind: "markdown" }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("leaves folds in code and inline tags as markdown", () => { | ||
| for (const markdown of [ | ||
| "```html\n<details>\n<summary>Code</summary>\n</details>\n```", | ||
| "Use `<details>` for folds.", | ||
| "Text <details><summary>inline</summary>body</details> more", | ||
| "<!-- <details> -->\n\nVisible answer.", | ||
| ]) { | ||
| expect(splitMarkdownDetails(markdown)).toEqual([ | ||
| { kind: "markdown", markdown, sourceOffset: 0 }, | ||
| ]); | ||
| } | ||
| }); | ||
|
|
||
| it("ignores a closing tag inside a code example in the fold", () => { | ||
| const markdown = | ||
| "<details>\n<summary>Example</summary>\n```html\n</details>\n```\n</details>\n\nAfter."; | ||
| expect(splitMarkdownDetails(markdown)).toMatchObject([ | ||
| { kind: "details", summary: "Example", body: "\n```html\n</details>\n```\n" }, | ||
| { kind: "markdown", markdown: "\n\nAfter." }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("closes a fold at a closing tag that ends a paragraph", () => { | ||
| expect( | ||
| splitMarkdownDetails("<details>\n<summary>Plan</summary>\n\nDone </details>\n\nNext step"), | ||
| ).toMatchObject([ | ||
| { kind: "details", summary: "Plan", body: "\n\nDone " }, | ||
| { kind: "markdown", markdown: "\n\nNext step" }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("folds the rest of a reply whose closing tag has not streamed yet", () => { | ||
| expect(splitMarkdownDetails("<details>\n<summary>Plan</summary>\n\nStep one")).toEqual([ | ||
| { kind: "details", summary: "Plan", body: "\n\nStep one", open: false, sourceOffset: 0 }, | ||
| ]); | ||
| expect( | ||
| splitMarkdownDetails("<details>\n<summary>Plan</summary>\n\n```html\n</details>"), | ||
| ).toMatchObject([{ kind: "details", body: "\n\n```html\n</details>" }]); | ||
| }); | ||
|
|
||
| it("reads open only as an attribute name", () => { | ||
| expect( | ||
| splitMarkdownDetails('<details title="is open">\n<summary>Plan</summary>\n</details>'), | ||
| ).toMatchObject([{ kind: "details", open: false }]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import type { RootContent } from "mdast"; | ||
| import { unified } from "unified"; | ||
| import remarkParse from "remark-parse"; | ||
|
|
||
| import { decodeHtmlEntities, markdownHtmlTags } from "./markdownDocument"; | ||
|
|
||
| export type MarkdownDetailsSegment = | ||
| | { readonly kind: "markdown"; readonly markdown: string; readonly sourceOffset: number } | ||
| | { | ||
| readonly kind: "details"; | ||
| readonly summary: string; | ||
| readonly body: string; | ||
| readonly open: boolean; | ||
| readonly sourceOffset: number; | ||
| }; | ||
|
|
||
| type OpenFold = { start: number; openTagEnd: number; open: boolean }; | ||
|
|
||
| const parser = unified().use(remarkParse); | ||
| const DETAILS_TAG = /^<(\/?)details\b/i; | ||
| const SUMMARY = /^\s*<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/i; | ||
|
|
||
| /** | ||
| * Splits top-level `<details>` blocks out of chat Markdown so they can render as folds. | ||
| * Tags inside code, or a fold opened inline in a paragraph, stay Markdown. A fold whose | ||
| * closing tag has not streamed yet runs to the end. | ||
| */ | ||
| export function splitMarkdownDetails(markdown: string): ReadonlyArray<MarkdownDetailsSegment> { | ||
| if (!/<details\b/i.test(markdown)) return [{ kind: "markdown", markdown, sourceOffset: 0 }]; | ||
|
|
||
| const folds: Array<OpenFold & { end: number; closeTagLength: number }> = []; | ||
| let depth = 0; | ||
| // Asserted so the scan closure's writes are not narrowed away after the loop. | ||
| let current = null as OpenFold | null; | ||
| // A fold opens only at block level; once open, a tag inside a paragraph can close it. | ||
| const scan = (node: RootContent, inline: boolean) => { | ||
| if (node.type !== "html") { | ||
| if (depth > 0 && "children" in node) { | ||
| for (const child of node.children) scan(child, true); | ||
| } | ||
| return; | ||
| } | ||
| if (node.position?.start.offset === undefined) return; | ||
| const nodeStart = node.position.start.offset; | ||
| for (const tag of markdownHtmlTags(node.value)) { | ||
| const details = DETAILS_TAG.exec(tag[0]); | ||
| if (!details || (inline && depth === 0)) continue; | ||
| const start = nodeStart + tag.index; | ||
| if (details[1] !== "/") { | ||
| if (depth === 0) { | ||
| // Quoted values are blanked so `title="is open"` does not count as the attribute. | ||
| const attributes = tag[0].replace(/"[^"]*"|'[^']*'/g, '""'); | ||
| current = { | ||
| start, | ||
| openTagEnd: start + tag[0].length, | ||
| open: /\sopen\b/i.test(attributes), | ||
| }; | ||
| } | ||
| depth += 1; | ||
| } else if (depth > 0) { | ||
| depth -= 1; | ||
| if (depth === 0 && current) { | ||
| folds.push({ ...current, end: start + tag[0].length, closeTagLength: tag[0].length }); | ||
| current = null; | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| for (const node of parser.parse(markdown).children) scan(node, false); | ||
| if (current) folds.push({ ...current, end: markdown.length, closeTagLength: 0 }); | ||
| if (folds.length === 0) return [{ kind: "markdown", markdown, sourceOffset: 0 }]; | ||
|
|
||
| const segments: MarkdownDetailsSegment[] = []; | ||
| let cursor = 0; | ||
| for (const fold of folds) { | ||
| if (fold.start > cursor) { | ||
| segments.push({ | ||
| kind: "markdown", | ||
| markdown: markdown.slice(cursor, fold.start), | ||
| sourceOffset: cursor, | ||
| }); | ||
| } | ||
| let body = markdown.slice(fold.openTagEnd, fold.end - fold.closeTagLength); | ||
| const summary = SUMMARY.exec(body); | ||
| if (summary) body = body.slice(summary[0].length); | ||
| const label = summary ? decodeHtmlEntities(summary[1]!.replace(/<[^>]*>/g, "")).trim() : ""; | ||
| segments.push({ | ||
| kind: "details", | ||
| summary: label || "Details", | ||
| body, | ||
| open: fold.open, | ||
| sourceOffset: fold.start, | ||
| }); | ||
| cursor = fold.end; | ||
| } | ||
| if (cursor < markdown.length) { | ||
| segments.push({ kind: "markdown", markdown: markdown.slice(cursor), sourceOffset: cursor }); | ||
| } | ||
| return segments; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Match the complete
detailstag name.If a reply contains a standalone
<details-panel>tag,\bmatches before the hyphen. The scan treats that custom tag as a fold and hides its body behind a disclosure control. Require whitespace,/, or>afterdetailsinstead. CommonMark permits standalone custom tags as HTML blocks. (spec.commonmark.org)Proposed fix
Committable suggestion