diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx index c7995b50fb..ac818bcadb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { Link, useLocation } from "react-router"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { cn } from "#/utils/cn"; +import { safeBuildAgentChatPath } from "../../../utils/navigation"; import { Response } from "../Response"; import { Shimmer } from "../Shimmer"; import { useDesktopPanel } from "./DesktopPanelContext"; @@ -187,6 +188,7 @@ export const SubagentTool: React.FC<{ const hasReport = Boolean(report?.trim()); const hasExpandableContent = hasPrompt || hasMessage || hasReport; const durationLabel = shortDurationMs(durationMs); + const agentChatPath = safeBuildAgentChatPath({ chatId }); return (
@@ -217,9 +219,9 @@ export const SubagentTool: React.FC<{ title, isTimeout, )} - {chatId && ( + {agentChatPath && ( e.stopPropagation()} className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100" aria-label="View agent" diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 2ecae66679..dc376696ac 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -426,6 +426,32 @@ export const SubagentRunning: Story = { }, }; +export const SubagentMalformedChatIdLinksToRecoverableChatId: Story = { + args: { + name: "spawn_agent", + status: "completed", + args: { + title: "Workspace diagnostics", + prompt: "Collect logs and summarize why startup failed.", + }, + result: { + chat_id: ["8f3a6131-1ce8-46f5-9", "b", "a8-4a36-beb2? no"].join(""), + title: "Workspace diagnostics", + status: "completed", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Spawned Workspace diagnostics/ }), + ).toBeInTheDocument(); + expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute( + "href", + ["/agents/8f3a6131-1ce8-46f5-9", "b", "a8-4a36-beb2"].join(""), + ); + }, +}; + export const ExploreSubagentRunning: Story = { args: { name: "spawn_explore_agent", diff --git a/site/src/pages/AgentsPage/utils/navigation.test.ts b/site/src/pages/AgentsPage/utils/navigation.test.ts new file mode 100644 index 0000000000..39b2965367 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/navigation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentChatPath, safeBuildAgentChatPath } from "./navigation"; + +describe("buildAgentChatPath", () => { + it("encodes chat IDs as a path segment", () => { + expect(buildAgentChatPath({ chatId: "chat/id" })).toBe("/agents/chat%2Fid"); + }); +}); + +describe("safeBuildAgentChatPath", () => { + it("returns a path for a safe chat ID", () => { + expect(safeBuildAgentChatPath({ chatId: "child-chat-id" })).toBe( + "/agents/child-chat-id", + ); + }); + + it("recovers a leading safe segment from a malformed chat ID", () => { + expect( + safeBuildAgentChatPath({ + chatId: ["8f3a6131-1ce8-46f5-9", "b", "a8-4a36-beb2? no"].join(""), + }), + ).toBe(["/agents/8f3a6131-1ce8-46f5-9", "b", "a8-4a36-beb2"].join("")); + }); + + it("returns null when no safe chat ID is recoverable", () => { + expect(safeBuildAgentChatPath({ chatId: "? no" })).toBeNull(); + expect(safeBuildAgentChatPath({ chatId: "chat/id" })).toBeNull(); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/navigation.ts b/site/src/pages/AgentsPage/utils/navigation.ts index fd16ff5b8a..09b8da4944 100644 --- a/site/src/pages/AgentsPage/utils/navigation.ts +++ b/site/src/pages/AgentsPage/utils/navigation.ts @@ -1,7 +1,30 @@ +const safeChatIdPattern = /^[A-Za-z0-9._~-]+$/; +const recoverableChatIdPrefixPattern = /^([A-Za-z0-9._~-]+)[?\s]/; + export const buildAgentChatPath = ({ chatId, }: Readonly<{ chatId: string; }>): string => { - return `/agents/${chatId}`; + return `/agents/${encodeURIComponent(chatId)}`; +}; + +export const safeBuildAgentChatPath = ({ + chatId, +}: Readonly<{ + chatId: string; +}>): string | null => { + const trimmedChatId = chatId.trim(); + if (safeChatIdPattern.test(trimmedChatId)) { + return buildAgentChatPath({ chatId: trimmedChatId }); + } + + const recoverableChatIdPrefix = trimmedChatId.match( + recoverableChatIdPrefixPattern, + )?.[1]; + if (!recoverableChatIdPrefix) { + return null; + } + + return buildAgentChatPath({ chatId: recoverableChatIdPrefix }); };