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 });
};