Detailed changes
@@ -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);
+});
@@ -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",
@@ -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"
}
@@ -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
@@ -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);
+}
@@ -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);
@@ -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 };
@@ -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>;
+ });
+}