From b898e45ec4caa4a690066e1e765f671ef726ba66 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 10 Mar 2026 05:57:59 -0700 Subject: [PATCH] feat(site): rewrite localhost URLs in agent chat to port-forward links (#22891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses streamdown's built-in `urlTransform` prop to intercept `http://localhost:PORT` URLs in agent chat messages and rewrite them to port-forwarded workspace URLs. When the agent outputs a bare URL like `http://localhost:3000` or a markdown link like `[app](http://localhost:8080/path)`, the URL is rewritten to the workspace's port-forward subdomain (e.g. `https://3000--agent--workspace--user.wildcard.host`). This makes links clickable directly from the chat without manual port-forwarding. ## How it works The transform is built in `AgentDetail` where workspace and proxy context are available, then threaded as an optional prop through the component tree: ``` AgentDetail → AgentDetailView → AgentDetailTimeline → ConversationTimeline → Response → Streamdown ``` - Uses streamdown's first-class `urlTransform` API — no monkey-patching or rehype plugins - Reuses the existing `portForwardURL()` utility from `utils/portForward` - Matches the same localhost detection as the terminal page (`localhost`, `127.0.0.1`, `0.0.0.0`) - Preserves pathname and search params - Gracefully degrades: when any required context is missing (no workspace, no wildcard proxy host), URLs pass through unchanged ## What gets transformed | Markdown input | Transformed? | |---|---| | `http://localhost:8080` (bare URL, auto-linked by remark-gfm) | Yes | | `[my app](http://localhost:3000/path)` (explicit link) | Yes | | `\`http://localhost:8080\`` (inline code) | No (correct — code spans are literal) | | `https://example.com` (non-localhost) | No | --- site/src/components/ai-elements/response.tsx | 10 ++++- .../pages/AgentsPage/AgentDetail.stories.tsx | 8 +++- site/src/pages/AgentsPage/AgentDetail.tsx | 39 +++++++++++++++++++ .../AgentDetail/ConversationTimeline.tsx | 39 ++++++++++++++++--- site/src/pages/AgentsPage/AgentDetailView.tsx | 5 +++ 5 files changed, 92 insertions(+), 9 deletions(-) diff --git a/site/src/components/ai-elements/response.tsx b/site/src/components/ai-elements/response.tsx index 1866d22a7e..e4e56736ad 100644 --- a/site/src/components/ai-elements/response.tsx +++ b/site/src/components/ai-elements/response.tsx @@ -5,11 +5,12 @@ import { } from "@pierre/diffs/react"; import type { ComponentPropsWithRef, ReactNode } from "react"; import { useMemo } from "react"; -import { type Components, Streamdown } from "streamdown"; +import { type Components, Streamdown, type UrlTransform } from "streamdown"; import { cn } from "utils/cn"; interface ResponseProps extends Omit, "children"> { children: string; + urlTransform?: UrlTransform; } const fileViewerCSS = @@ -127,6 +128,7 @@ export const Response = ({ className, children, ref, + urlTransform, ...props }: ResponseProps) => { const theme = useTheme(); @@ -147,7 +149,11 @@ export const Response = ({ )} {...props} > - + {children} diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 44b62fda13..6d9865cbd1 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -6,6 +6,7 @@ import { import { withAuthProvider, withDashboardProvider, + withProxyProvider, withWebSocket, } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -177,7 +178,12 @@ const wrapSSE = (payload: unknown): string => const meta: Meta = { title: "pages/AgentsPage/AgentDetail", component: AgentDetailLayout, - decorators: [withAuthProvider, withDashboardProvider, withWebSocket], + decorators: [ + withAuthProvider, + withDashboardProvider, + withProxyProvider(), + withWebSocket, + ], parameters: { layout: "fullscreen", user: MockUserOwner, diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 8b6fb32cef..eba5e4caf4 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -15,6 +15,7 @@ import { deploymentSSHConfig } from "api/queries/deployment"; import { workspaceById, workspaceByIdKey } from "api/queries/workspaces"; import type * as TypesGen from "api/typesGenerated"; import type { ModelSelectorOption } from "components/ai-elements"; +import { useProxy } from "contexts/ProxyContext"; import { getTerminalHref, getVSCodeHref, @@ -32,7 +33,9 @@ import { import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useOutletContext, useParams } from "react-router"; import { toast } from "sonner"; +import type { UrlTransform } from "streamdown"; import { pageTitle } from "utils/page"; +import { portForwardURL } from "utils/portForward"; import { AgentChatInput, type ChatMessageInputRef, @@ -80,6 +83,8 @@ import { import { useFileAttachments } from "./useFileAttachments"; import { useGitWatcher } from "./useGitWatcher"; +const localHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0"]); + const lastModelConfigIDStorageKey = "agents.last-model-config-id"; /** @internal Exported for testing. */ export const draftInputStorageKeyPrefix = "agents.draft-input."; @@ -100,6 +105,7 @@ interface AgentDetailTimelineProps { ) => void; editingMessageId?: number | null; savingMessageId?: number | null; + urlTransform?: UrlTransform; } export const AgentDetailTimeline: FC = ({ @@ -109,6 +115,7 @@ export const AgentDetailTimeline: FC = ({ onEditUserMessage, editingMessageId, savingMessageId, + urlTransform, }) => { const messagesByID = useChatSelector(store, selectMessagesByID); const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); @@ -177,6 +184,7 @@ export const AgentDetailTimeline: FC = ({ onEditUserMessage={onEditUserMessage} editingMessageId={editingMessageId} savingMessageId={savingMessageId} + urlTransform={urlTransform} /> ); }; @@ -608,6 +616,36 @@ const AgentDetail: FC = () => { const sshConfigQuery = useQuery(deploymentSSHConfig()); const workspace = workspaceQuery.data; const workspaceAgent = getWorkspaceAgent(workspace, undefined); + const { proxy } = useProxy(); + + const urlTransform = useCallback( + (url) => { + const host = proxy.preferredWildcardHostname; + if (!host || !workspaceAgent || !workspace) { + return url; + } + try { + const parsed = new URL(url); + if (!localHosts.has(parsed.hostname)) { + return url; + } + return portForwardURL( + host, + Number.parseInt(parsed.port, 10), + workspaceAgent.name, + workspace.name, + workspace.owner_name, + "http", + parsed.pathname, + parsed.search, + ); + } catch { + return url; + } + }, + [proxy.preferredWildcardHostname, workspaceAgent, workspace], + ); + const chatData = chatQuery.data; const chatRecord = chatData?.chat; const isArchived = chatRecord?.archived ?? false; @@ -1084,6 +1122,7 @@ const AgentDetail: FC = () => { handleArchiveAndDeleteWorkspaceAction={ handleArchiveAndDeleteWorkspaceAction } + urlTransform={urlTransform} scrollContainerRef={scrollContainerRef} /> ); diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index 26433bbd42..db8260d89a 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -19,6 +19,7 @@ import { useRef, useState, } from "react"; +import type { UrlTransform } from "streamdown"; import { cn } from "utils/cn"; import { ImageThumbnail } from "../AgentChatInput"; import { ImageLightbox } from "../ImageLightbox"; @@ -36,7 +37,8 @@ const ReasoningDisclosure: FC<{ title?: string; text: string; isStreaming?: boolean; -}> = ({ id, title, text, isStreaming = false }) => { + urlTransform?: UrlTransform; +}> = ({ id, title, text, isStreaming = false, urlTransform }) => { const [isOpen, setIsOpen] = useState(false); const hasText = text.trim().length > 0; const label = title ?? "Thinking"; @@ -45,7 +47,10 @@ const ReasoningDisclosure: FC<{ if (!title && hasText) { return (
- + {text}
@@ -87,7 +92,10 @@ const ReasoningDisclosure: FC<{ )} {isOpen && hasText ? (
- + {text}
@@ -107,6 +115,7 @@ type RenderBlockListParams = { subagentTitles?: Map; subagentStatusOverrides?: Map; onImageClick?: (src: string) => void; + urlTransform?: UrlTransform; }; // Wrapper that runs the smooth-streaming jitter buffer on a single @@ -115,14 +124,15 @@ type RenderBlockListParams = { const SmoothedResponse: FC<{ text: string; streamKey: string; -}> = ({ text, streamKey }) => { + urlTransform?: UrlTransform; +}> = ({ text, streamKey, urlTransform }) => { const { visibleText } = useSmoothStreamingText({ fullText: text, isStreaming: true, bypassSmoothing: false, streamKey, }); - return {visibleText}; + return {visibleText}; }; type RenderBlockListResult = { @@ -138,6 +148,7 @@ function renderBlockList({ subagentTitles, subagentStatusOverrides, onImageClick, + urlTransform, }: RenderBlockListParams): RenderBlockListResult { const renderedToolIDs = new Set(); const elements = blocks @@ -149,9 +160,13 @@ function renderBlockList({ key={`${keyPrefix}-response-${index}`} text={block.text} streamKey={keyPrefix} + urlTransform={urlTransform} /> ) : ( - + {block.text} ); @@ -163,6 +178,7 @@ function renderBlockList({ title={block.title} text={block.text} isStreaming={isStreaming} + urlTransform={urlTransform} /> ); case "file-reference": @@ -267,6 +283,7 @@ const ChatMessageItem = memo<{ // that fades text out toward the bottom. Used by the sticky // overlay to indicate truncated content. fadeFromBottom?: boolean; + urlTransform?: UrlTransform; }>( ({ message, @@ -275,6 +292,7 @@ const ChatMessageItem = memo<{ editingMessageId, savingMessageId, fadeFromBottom = false, + urlTransform, }) => { const isUser = message.role === "user"; const isSavingMessage = savingMessageId === message.id; @@ -300,6 +318,7 @@ const ChatMessageItem = memo<{ toolByID, keyPrefix: String(message.id), onImageClick: setPreviewImage, + urlTransform, }); const remainingTools = parsed.tools.filter( (tool) => !renderedToolIDs.has(tool.id), @@ -483,6 +502,7 @@ export const StreamingOutput = memo<{ subagentStatusOverrides?: Map; showInitialPlaceholder?: boolean; retryState?: { attempt: number; error: string } | null; + urlTransform?: UrlTransform; }>( ({ streamState, @@ -491,6 +511,7 @@ export const StreamingOutput = memo<{ subagentStatusOverrides, showInitialPlaceholder = false, retryState, + urlTransform, }) => { const conversationItemProps = { role: "assistant" as const }; const toolByID = new Map(streamTools.map((tool) => [tool.id, tool])); @@ -502,6 +523,7 @@ export const StreamingOutput = memo<{ isStreaming: true, subagentTitles, subagentStatusOverrides, + urlTransform, }); const remainingTools = streamTools.filter( (tool) => !renderedToolIDs.has(tool.id), @@ -824,6 +846,7 @@ interface ConversationTimelineProps { ) => void; editingMessageId?: number | null; savingMessageId?: number | null; + urlTransform?: UrlTransform; } export const ConversationTimeline: FC = ({ @@ -842,6 +865,7 @@ export const ConversationTimeline: FC = ({ onEditUserMessage, editingMessageId, savingMessageId, + urlTransform, }) => { const shouldRenderStreamInLastSection = hasStreamOutput && parsedSections.length > 0; @@ -888,6 +912,7 @@ export const ConversationTimeline: FC = ({ message={message} parsed={parsed} savingMessageId={savingMessageId} + urlTransform={urlTransform} /> ), )} @@ -900,6 +925,7 @@ export const ConversationTimeline: FC = ({ subagentStatusOverrides={subagentStatusOverrides} showInitialPlaceholder={isAwaitingFirstStreamChunk} retryState={retryState} + urlTransform={urlTransform} /> )} @@ -913,6 +939,7 @@ export const ConversationTimeline: FC = ({ subagentStatusOverrides={subagentStatusOverrides} showInitialPlaceholder={isAwaitingFirstStreamChunk} retryState={retryState} + urlTransform={urlTransform} /> )} diff --git a/site/src/pages/AgentsPage/AgentDetailView.tsx b/site/src/pages/AgentsPage/AgentDetailView.tsx index d4bc074a50..759dd36a04 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.tsx @@ -4,6 +4,7 @@ import type { ModelSelectorOption } from "components/ai-elements"; import { Skeleton } from "components/Skeleton/Skeleton"; import { ArchiveIcon } from "lucide-react"; import { type FC, type RefObject, useState } from "react"; +import type { UrlTransform } from "streamdown"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; @@ -110,6 +111,8 @@ interface AgentDetailViewProps { // Scroll container ref. scrollContainerRef: RefObject; + + urlTransform?: UrlTransform; } export const AgentDetailView: FC = ({ @@ -154,6 +157,7 @@ export const AgentDetailView: FC = ({ handleUnarchiveAgentAction, handleArchiveAndDeleteWorkspaceAction, scrollContainerRef, + urlTransform, }) => { // Panel/sidebar UI state – purely visual, no data-fetching // implications. @@ -267,6 +271,7 @@ export const AgentDetailView: FC = ({ onEditUserMessage={editing.handleEditUserMessage} editingMessageId={editing.editingMessageId} savingMessageId={pendingEditMessageId} + urlTransform={urlTransform} />