shelley: linkify HTTP/HTTPS URLs in LLM responses

Philip Zeyliger created

Prompt: I'm a new work tree have the http/https URLs produced by the LLM converted to html links. Be safe via a vis parsing. Add some js tests for the parsing you do. (I don't think we have a test infra; add something.)

- Add linkify.tsx utility that safely parses and converts URLs to clickable links
- Modify Message.tsx to use linkifyText for text content
- Add CSS styling for links with proper hover states
- Add 26 unit tests for URL parsing edge cases
- Add Playwright e2e test for URL linkification

Security: Only matches http:// and https:// URLs (not javascript:, etc.)
Links open in new tabs with rel="noopener noreferrer"

Change summary

ui/e2e/linkify.spec.ts              |  57 +++
ui/package-lock.json                | 512 +++++++++++++++++++++++++++++++
ui/package.json                     |   2 
ui/src/components/Message.tsx       |   5 
ui/src/styles.css                   |  12 
ui/src/utils/linkify.test.runner.ts |  18 +
ui/src/utils/linkify.test.ts        | 281 +++++++++++++++++
ui/src/utils/linkify.tsx            |  90 +++++
8 files changed, 976 insertions(+), 1 deletion(-)

Detailed changes

ui/e2e/linkify.spec.ts 🔗

@@ -0,0 +1,57 @@
+import { test, expect } from "@playwright/test";
+
+// Test that URLs in agent responses are properly linkified
+// This test requires the shelley server to be running with predictable model
+
+test("URLs in agent responses should be linkified", async ({ page }) => {
+  // Navigate to the app
+  await page.goto("http://localhost:8002");
+
+  // Wait for the app to load
+  await expect(page.locator("[data-testid='message-input']")).toBeVisible();
+
+  // Type a message that will trigger a predictable response with URLs
+  await page.locator("[data-testid='message-input']").fill("echo: Check https://example.com and https://test.com");
+  
+  // Click send
+  await page.getByRole("button", { name: "Send message" }).click();
+  
+  // Wait for response
+  await page.waitForSelector(".message-agent", { timeout: 5000 });
+  
+  // Check that URLs are linkified
+  const agentMessage = page.locator(".message-agent .text-link").first();
+  await expect(agentMessage).toBeVisible();
+  await expect(agentMessage).toHaveAttribute("href", "https://example.com/");
+  await expect(agentMessage).toHaveAttribute("target", "_blank");
+  await expect(agentMessage).toHaveAttribute("rel", "noopener noreferrer");
+  
+  // Check second URL
+  const secondLink = page.locator(".message-agent .text-link").nth(1);
+  await expect(secondLink).toBeVisible();
+  await expect(secondLink).toHaveAttribute("href", "https://test.com/");
+});
+
+test("URLs should not be linkified in user messages", async ({ page }) => {
+  // Navigate to the app
+  await page.goto("http://localhost:8002");
+
+  // Wait for the app to load
+  await expect(page.locator("[data-testid='message-input']")).toBeVisible();
+
+  // Type a message with URLs
+  await page.locator("[data-testid='message-input']").fill("echo: Visit https://example.com");
+  
+  // Click send
+  await page.getByRole("button", { name: "Send message" }).click();
+  
+  // Wait for response
+  await page.waitForSelector(".message-user", { timeout: 5000 });
+  
+  // User messages should show raw text, not linkified
+  const userMessage = page.locator(".message-user");
+  await expect(userMessage).toContainText("echo: Visit https://example.com");
+  
+  // But should not have link elements
+  await expect(userMessage.locator("a.text-link")).toHaveCount(0);
+});

ui/package-lock.json 🔗

@@ -24,6 +24,7 @@
         "eslint-plugin-react": "^7.37.5",
         "eslint-plugin-react-hooks": "^5.2.0",
         "prettier": "^3.6.2",
+        "tsx": "^4.21.0",
         "typescript": "^5.0.0",
         "typescript-eslint": "^8.43.0"
       }
@@ -317,6 +318,22 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/netbsd-x64": {
       "version": "0.19.12",
       "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
@@ -334,6 +351,22 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/openbsd-x64": {
       "version": "0.19.12",
       "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
@@ -351,6 +384,22 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/sunos-x64": {
       "version": "0.19.12",
       "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
@@ -2305,6 +2354,18 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/get-tsconfig": {
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
+      "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
+      "dev": true,
+      "dependencies": {
+        "resolve-pkg-maps": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+      }
+    },
     "node_modules/glob-parent": {
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3566,6 +3627,15 @@
         "node": ">=4"
       }
     },
+    "node_modules/resolve-pkg-maps": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+      }
+    },
     "node_modules/reusify": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -4003,6 +4073,448 @@
         "typescript": ">=4.8.4"
       }
     },
+    "node_modules/tsx": {
+      "version": "4.21.0",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "~0.27.0",
+        "get-tsconfig": "^4.7.5"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/android-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/android-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/android-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/win32-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/esbuild": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.2",
+        "@esbuild/android-arm": "0.27.2",
+        "@esbuild/android-arm64": "0.27.2",
+        "@esbuild/android-x64": "0.27.2",
+        "@esbuild/darwin-arm64": "0.27.2",
+        "@esbuild/darwin-x64": "0.27.2",
+        "@esbuild/freebsd-arm64": "0.27.2",
+        "@esbuild/freebsd-x64": "0.27.2",
+        "@esbuild/linux-arm": "0.27.2",
+        "@esbuild/linux-arm64": "0.27.2",
+        "@esbuild/linux-ia32": "0.27.2",
+        "@esbuild/linux-loong64": "0.27.2",
+        "@esbuild/linux-mips64el": "0.27.2",
+        "@esbuild/linux-ppc64": "0.27.2",
+        "@esbuild/linux-riscv64": "0.27.2",
+        "@esbuild/linux-s390x": "0.27.2",
+        "@esbuild/linux-x64": "0.27.2",
+        "@esbuild/netbsd-arm64": "0.27.2",
+        "@esbuild/netbsd-x64": "0.27.2",
+        "@esbuild/openbsd-arm64": "0.27.2",
+        "@esbuild/openbsd-x64": "0.27.2",
+        "@esbuild/openharmony-arm64": "0.27.2",
+        "@esbuild/sunos-x64": "0.27.2",
+        "@esbuild/win32-arm64": "0.27.2",
+        "@esbuild/win32-ia32": "0.27.2",
+        "@esbuild/win32-x64": "0.27.2"
+      }
+    },
+    "node_modules/tsx/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

ui/package.json 🔗

@@ -12,6 +12,7 @@
     "type-check": "tsc --noEmit",
     "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,html}'",
     "format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,html}'",
+    "test": "tsx src/utils/linkify.test.runner.ts",
     "generate-types": "cd .. && go run ./cmd/go2ts.go -o ui/src/generated-types.ts",
     "test:e2e": "npm run build && playwright test",
     "test:e2e:headed": "npm run build && playwright test --headed",
@@ -35,6 +36,7 @@
     "eslint-plugin-react": "^7.37.5",
     "eslint-plugin-react-hooks": "^5.2.0",
     "prettier": "^3.6.2",
+    "tsx": "^4.21.0",
     "typescript": "^5.0.0",
     "typescript-eslint": "^8.43.0"
   }

ui/src/components/Message.tsx 🔗

@@ -1,4 +1,5 @@
 import React, { useState, useRef } from "react";
+import { linkifyText } from "../utils/linkify";
 import { Message as MessageType, LLMMessage, LLMContent, Usage } from "../types";
 import BashTool from "./BashTool";
 import PatchTool from "./PatchTool";
@@ -336,7 +337,9 @@ function Message({ message, onOpenDiffViewer }: MessageProps) {
           </div>
         );
       case "text":
-        return <div className="whitespace-pre-wrap break-words">{content.Text || ""}</div>;
+        return (
+          <div className="whitespace-pre-wrap break-words">{linkifyText(content.Text || "")}</div>
+        );
       case "tool_use":
         // IMPORTANT: When adding a new tool component here, also add it to:
         // 1. The tool_result case below

ui/src/styles.css 🔗

@@ -3163,3 +3163,15 @@ svg {
 .injected-text-insert-btn:hover {
   filter: brightness(1.1);
 }
+
+/* Links in message text */
+.text-link {
+  color: var(--blue-text);
+  text-decoration: underline;
+  text-underline-offset: 2px;
+  text-decoration-color: var(--blue-border);
+}
+
+.text-link:hover {
+  text-decoration-color: var(--blue-text);
+}

ui/src/utils/linkify.test.runner.ts 🔗

@@ -0,0 +1,18 @@
+// Test runner for linkify tests - can be run with tsx/ts-node
+import { runTests } from "./linkify.test";
+
+const { passed, failed, failures } = runTests();
+
+console.log(`\nLinkify Tests: ${passed} passed, ${failed} failed\n`);
+
+if (failures.length > 0) {
+  console.log("Failures:");
+  for (const f of failures) {
+    console.log(f);
+    console.log("");
+  }
+  process.exit(1);
+}
+
+console.log("All tests passed!");
+process.exit(0);

ui/src/utils/linkify.test.ts 🔗

@@ -0,0 +1,281 @@
+import { parseLinks, LinkifyResult } from "./linkify";
+
+interface TestCase {
+  name: string;
+  input: string;
+  expected: LinkifyResult[];
+}
+
+const testCases: TestCase[] = [
+  {
+    name: "plain text with no URLs",
+    input: "Hello world",
+    expected: [{ type: "text", content: "Hello world" }],
+  },
+  {
+    name: "simple http URL",
+    input: "Check out http://example.com for more",
+    expected: [
+      { type: "text", content: "Check out " },
+      { type: "link", content: "http://example.com", href: "http://example.com" },
+      { type: "text", content: " for more" },
+    ],
+  },
+  {
+    name: "simple https URL",
+    input: "Visit https://example.com today",
+    expected: [
+      { type: "text", content: "Visit " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: " today" },
+    ],
+  },
+  {
+    name: "URL with path",
+    input: "See https://example.com/path/to/page for details",
+    expected: [
+      { type: "text", content: "See " },
+      {
+        type: "link",
+        content: "https://example.com/path/to/page",
+        href: "https://example.com/path/to/page",
+      },
+      { type: "text", content: " for details" },
+    ],
+  },
+  {
+    name: "URL with query parameters",
+    input: "Link: https://example.com/search?q=test&page=1",
+    expected: [
+      { type: "text", content: "Link: " },
+      {
+        type: "link",
+        content: "https://example.com/search?q=test&page=1",
+        href: "https://example.com/search?q=test&page=1",
+      },
+    ],
+  },
+  {
+    name: "URL with port",
+    input: "Server at https://localhost:8080/api",
+    expected: [
+      { type: "text", content: "Server at " },
+      {
+        type: "link",
+        content: "https://localhost:8080/api",
+        href: "https://localhost:8080/api",
+      },
+    ],
+  },
+  {
+    name: "URL followed by period (sentence end)",
+    input: "Check https://example.com.",
+    expected: [
+      { type: "text", content: "Check " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: "." },
+    ],
+  },
+  {
+    name: "URL followed by comma",
+    input: "Visit https://example.com, then continue",
+    expected: [
+      { type: "text", content: "Visit " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: ", then continue" },
+    ],
+  },
+  {
+    name: "URL followed by exclamation",
+    input: "Wow https://example.com!",
+    expected: [
+      { type: "text", content: "Wow " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: "!" },
+    ],
+  },
+  {
+    name: "URL followed by question mark",
+    input: "Have you seen https://example.com?",
+    expected: [
+      { type: "text", content: "Have you seen " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: "?" },
+    ],
+  },
+  {
+    name: "multiple URLs",
+    input: "Try https://a.com and https://b.com too",
+    expected: [
+      { type: "text", content: "Try " },
+      { type: "link", content: "https://a.com", href: "https://a.com" },
+      { type: "text", content: " and " },
+      { type: "link", content: "https://b.com", href: "https://b.com" },
+      { type: "text", content: " too" },
+    ],
+  },
+  {
+    name: "URL at start of text",
+    input: "https://example.com is the site",
+    expected: [
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: " is the site" },
+    ],
+  },
+  {
+    name: "URL at end of text",
+    input: "The site is https://example.com",
+    expected: [
+      { type: "text", content: "The site is " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+    ],
+  },
+  {
+    name: "URL only",
+    input: "https://example.com",
+    expected: [{ type: "link", content: "https://example.com", href: "https://example.com" }],
+  },
+  {
+    name: "empty string",
+    input: "",
+    expected: [],
+  },
+  {
+    name: "URL with fragment",
+    input: "See https://example.com/page#section for more",
+    expected: [
+      { type: "text", content: "See " },
+      {
+        type: "link",
+        content: "https://example.com/page#section",
+        href: "https://example.com/page#section",
+      },
+      { type: "text", content: " for more" },
+    ],
+  },
+  {
+    name: "URL in parentheses - should not include closing paren",
+    input: "(see https://example.com)",
+    expected: [
+      { type: "text", content: "(see " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: ")" },
+    ],
+  },
+  {
+    name: "URL with trailing colon and more text",
+    input: "URL: https://example.com: that was it",
+    expected: [
+      { type: "text", content: "URL: " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: ": that was it" },
+    ],
+  },
+  {
+    name: "does not match ftp URLs",
+    input: "Not matched: ftp://example.com",
+    expected: [{ type: "text", content: "Not matched: ftp://example.com" }],
+  },
+  {
+    name: "does not match mailto",
+    input: "Email: mailto:test@example.com",
+    expected: [{ type: "text", content: "Email: mailto:test@example.com" }],
+  },
+  {
+    name: "URL with underscores and dashes",
+    input: "Go to https://my-site.example.com/some_page",
+    expected: [
+      { type: "text", content: "Go to " },
+      {
+        type: "link",
+        content: "https://my-site.example.com/some_page",
+        href: "https://my-site.example.com/some_page",
+      },
+    ],
+  },
+  {
+    name: "URL followed by semicolon",
+    input: "First https://a.com; then more",
+    expected: [
+      { type: "text", content: "First " },
+      { type: "link", content: "https://a.com", href: "https://a.com" },
+      { type: "text", content: "; then more" },
+    ],
+  },
+  {
+    name: "newlines around URL",
+    input: "Line 1\nhttps://example.com\nLine 3",
+    expected: [
+      { type: "text", content: "Line 1\n" },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: "\nLine 3" },
+    ],
+  },
+  {
+    name: "XSS attempt in URL - javascript protocol not matched",
+    input: "javascript:alert('xss')",
+    expected: [{ type: "text", content: "javascript:alert('xss')" }],
+  },
+  {
+    name: "XSS attempt - script tags in text preserved as text",
+    input: "<script>alert('xss')</script> https://example.com",
+    expected: [
+      { type: "text", content: "<script>alert('xss')</script> " },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+    ],
+  },
+  {
+    name: "URL not matched inside angle brackets",
+    input: "See <https://example.com> for more",
+    expected: [
+      { type: "text", content: "See <" },
+      { type: "link", content: "https://example.com", href: "https://example.com" },
+      { type: "text", content: "> for more" },
+    ],
+  },
+];
+
+function deepEqual(a: unknown, b: unknown): boolean {
+  if (a === b) return true;
+  if (typeof a !== typeof b) return false;
+  if (a === null || b === null) return a === b;
+  if (typeof a !== "object") return false;
+
+  if (Array.isArray(a) && Array.isArray(b)) {
+    if (a.length !== b.length) return false;
+    return a.every((item, i) => deepEqual(item, b[i]));
+  }
+
+  if (Array.isArray(a) || Array.isArray(b)) return false;
+
+  const aObj = a as Record<string, unknown>;
+  const bObj = b as Record<string, unknown>;
+  const aKeys = Object.keys(aObj);
+  const bKeys = Object.keys(bObj);
+
+  if (aKeys.length !== bKeys.length) return false;
+  return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
+}
+
+export function runTests(): { passed: number; failed: number; failures: string[] } {
+  let passed = 0;
+  let failed = 0;
+  const failures: string[] = [];
+
+  for (const tc of testCases) {
+    const result = parseLinks(tc.input);
+    if (deepEqual(result, tc.expected)) {
+      passed++;
+    } else {
+      failed++;
+      failures.push(
+        `FAIL: ${tc.name}\n  Input: ${JSON.stringify(tc.input)}\n  Expected: ${JSON.stringify(tc.expected)}\n  Got: ${JSON.stringify(result)}`,
+      );
+    }
+  }
+
+  return { passed, failed, failures };
+}
+
+// Export test cases for use in browser
+export { testCases };

ui/src/utils/linkify.tsx 🔗

@@ -0,0 +1,90 @@
+import React from "react";
+
+// Regex for matching URLs. Only matches http:// and https:// URLs.
+// Avoids matching trailing punctuation that's likely not part of the URL.
+// eslint-disable-next-line no-useless-escape
+const URL_REGEX = /https?:\/\/[^\s<>"'`\]\)]+[^\s<>"'`\]\).,:;!?]/g;
+
+export interface LinkifyResult {
+  type: "text" | "link";
+  content: string;
+  href?: string;
+}
+
+/**
+ * Parse text and extract URLs as separate segments.
+ * Returns an array of text and link segments.
+ */
+export function parseLinks(text: string): LinkifyResult[] {
+  const results: LinkifyResult[] = [];
+  let lastIndex = 0;
+
+  // Reset regex state
+  URL_REGEX.lastIndex = 0;
+
+  let match;
+  while ((match = URL_REGEX.exec(text)) !== null) {
+    // Add text before the match
+    if (match.index > lastIndex) {
+      results.push({
+        type: "text",
+        content: text.slice(lastIndex, match.index),
+      });
+    }
+
+    // Add the link
+    const url = match[0];
+    results.push({
+      type: "link",
+      content: url,
+      href: url,
+    });
+
+    lastIndex = match.index + url.length;
+  }
+
+  // Add remaining text after last match
+  if (lastIndex < text.length) {
+    results.push({
+      type: "text",
+      content: text.slice(lastIndex),
+    });
+  }
+
+  return results;
+}
+
+/**
+ * Convert text containing URLs into React elements with clickable links.
+ * URLs are rendered as <a> tags that open in new tabs.
+ * Text is HTML-escaped by React's default behavior.
+ */
+export function linkifyText(text: string): React.ReactNode {
+  const segments = parseLinks(text);
+
+  if (segments.length === 0) {
+    return text;
+  }
+
+  // If there's only one text segment with no links, return plain text
+  if (segments.length === 1 && segments[0].type === "text") {
+    return text;
+  }
+
+  return segments.map((segment, index) => {
+    if (segment.type === "link") {
+      return (
+        <a
+          key={index}
+          href={segment.href}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-link"
+        >
+          {segment.content}
+        </a>
+      );
+    }
+    return <React.Fragment key={index}>{segment.content}</React.Fragment>;
+  });
+}