diff --git a/ui/e2e/linkify.spec.ts b/ui/e2e/linkify.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f7de070c42d3b7e07028f8edd59e022e66a5162 --- /dev/null +++ b/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); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index fc32f41228c904c69d48eb8d9dd159d07ce67c0d..0496b3c92149c3e45e028294939317a706f84168 100644 --- a/ui/package-lock.json +++ b/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", diff --git a/ui/package.json b/ui/package.json index 475a5f66896a951433e29f0c59eaf26b922f4465..8ffb417236975058a1cc7d8ba501fd5e629747f0 100644 --- a/ui/package.json +++ b/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" } diff --git a/ui/src/components/Message.tsx b/ui/src/components/Message.tsx index 48d607b4ade7309f6d42a79656e675df2389f44a..41207bc7f92a8b46dab13e6e879e4a95522276ef 100644 --- a/ui/src/components/Message.tsx +++ b/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) { ); case "text": - return
{content.Text || ""}
; + return ( +
{linkifyText(content.Text || "")}
+ ); case "tool_use": // IMPORTANT: When adding a new tool component here, also add it to: // 1. The tool_result case below diff --git a/ui/src/styles.css b/ui/src/styles.css index a72047938348f7c9531df28c9a1423afb7ee8c14..0f6f68ca9faa85ef1b4f560281482fa33cb0de57 100644 --- a/ui/src/styles.css +++ b/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); +} diff --git a/ui/src/utils/linkify.test.runner.ts b/ui/src/utils/linkify.test.runner.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc56aa9759e1b39ca500418bff4cb3a88f312f26 --- /dev/null +++ b/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); diff --git a/ui/src/utils/linkify.test.ts b/ui/src/utils/linkify.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c88381cc1d12b117c1959ec6dde2fa4f19f8d7f --- /dev/null +++ b/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: " https://example.com", + expected: [ + { type: "text", content: " " }, + { type: "link", content: "https://example.com", href: "https://example.com" }, + ], + }, + { + name: "URL not matched inside angle brackets", + input: "See 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; + const bObj = b as Record; + 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 }; diff --git a/ui/src/utils/linkify.tsx b/ui/src/utils/linkify.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e320f75192554f6a47e8ed82fe0a79c9ce1e13a0 --- /dev/null +++ b/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 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 ( + + {segment.content} + + ); + } + return {segment.content}; + }); +}