diff --git a/openbot/app/src/components/duet/page-text.tsx b/openbot/app/src/components/duet/page-text.tsx
index c73c05f9b..e340016ca 100644
--- a/openbot/app/src/components/duet/page-text.tsx
+++ b/openbot/app/src/components/duet/page-text.tsx
@@ -6,16 +6,26 @@ export function PageText({ text }: { text: string }) {
let cursor = 0;
for (const match of text.matchAll(/https?:\/\/[^\s<>"']+/g)) {
const start = match.index;
- let source = match[0].replace(/[.,;:!?]+$/, "");
- for (const [open, close] of [
- ["(", ")"],
- ["[", "]"],
- ]) {
- while (
- source.endsWith(close) &&
- source.split(close).length > source.split(open).length
- ) {
- source = source.slice(0, -1);
+ // Trim trailing sentence punctuation, then drop unmatched closers — and
+ // repeat, because a closer can hide punctuation behind it:
+ // "(see https://example.com/pricing.)" trims nothing first (the "."
+ // is not last), drops the ")", and only then exposes the ".".
+ // Balanced parentheses inside a URL are preserved throughout.
+ let source = match[0];
+ let previous = "";
+ while (source !== previous) {
+ previous = source;
+ source = source.replace(/[.,;:!?]+$/, "");
+ for (const [open, close] of [
+ ["(", ")"],
+ ["[", "]"],
+ ]) {
+ while (
+ source.endsWith(close) &&
+ source.split(close).length > source.split(open).length
+ ) {
+ source = source.slice(0, -1);
+ }
}
}
let url: URL;
diff --git a/openbot/app/tests/pages.test.tsx b/openbot/app/tests/pages.test.tsx
index 6ae974ed1..6ac9ca15f 100644
--- a/openbot/app/tests/pages.test.tsx
+++ b/openbot/app/tests/pages.test.tsx
@@ -931,6 +931,20 @@ test("a research page renders source URLs as links without changing its text", (
}
});
+test("source links drop punctuation left behind by an unmatched closing bracket", () => {
+ const text =
+ "(See https://example.com/pricing.) [See https://example.org/plans,] (See https://example.com/wiki/Project_(software).)";
+ const view = render();
+ expect(
+ view.getAllByRole("link").map((link) => link.getAttribute("href")),
+ ).toEqual([
+ "https://example.com/pricing",
+ "https://example.org/plans",
+ "https://example.com/wiki/Project_(software)",
+ ]);
+ expect(view.container.textContent).toBe(text);
+});
+
test("source links keep balanced URL parentheses and support HTTP sources", () => {
const text = "https://example.com/wiki/Project_(software) http://example.org";
const view = render();