feat(site): rewrite localhost URLs in agent chat to port-forward links (#22891)

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 |
This commit is contained in:
Kyle Carberry
2026-03-10 12:57:59 +00:00
committed by GitHub
parent d61772dc52
commit b898e45ec4
5 changed files with 92 additions and 9 deletions
+8 -2
View File
@@ -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<ComponentPropsWithRef<"div">, "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}
>
<Streamdown controls={false} components={components}>
<Streamdown
controls={false}
components={components}
urlTransform={urlTransform}
>
{children}
</Streamdown>
</div>
@@ -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<typeof AgentDetailLayout> = {
title: "pages/AgentsPage/AgentDetail",
component: AgentDetailLayout,
decorators: [withAuthProvider, withDashboardProvider, withWebSocket],
decorators: [
withAuthProvider,
withDashboardProvider,
withProxyProvider(),
withWebSocket,
],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
+39
View File
@@ -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<AgentDetailTimelineProps> = ({
@@ -109,6 +115,7 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
onEditUserMessage,
editingMessageId,
savingMessageId,
urlTransform,
}) => {
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
@@ -177,6 +184,7 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
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<UrlTransform>(
(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}
/>
);
@@ -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 (
<div className="w-full">
<Response className="text-[11px] text-content-secondary">
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
>
{text}
</Response>
</div>
@@ -87,7 +92,10 @@ const ReasoningDisclosure: FC<{
)}
{isOpen && hasText ? (
<div id={id} className="mt-1.5">
<Response className="text-[11px] text-content-secondary">
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
>
{text}
</Response>
</div>
@@ -107,6 +115,7 @@ type RenderBlockListParams = {
subagentTitles?: Map<string, string>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
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 <Response>{visibleText}</Response>;
return <Response urlTransform={urlTransform}>{visibleText}</Response>;
};
type RenderBlockListResult = {
@@ -138,6 +148,7 @@ function renderBlockList({
subagentTitles,
subagentStatusOverrides,
onImageClick,
urlTransform,
}: RenderBlockListParams): RenderBlockListResult {
const renderedToolIDs = new Set<string>();
const elements = blocks
@@ -149,9 +160,13 @@ function renderBlockList({
key={`${keyPrefix}-response-${index}`}
text={block.text}
streamKey={keyPrefix}
urlTransform={urlTransform}
/>
) : (
<Response key={`${keyPrefix}-response-${index}`}>
<Response
key={`${keyPrefix}-response-${index}`}
urlTransform={urlTransform}
>
{block.text}
</Response>
);
@@ -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<string, TypesGen.ChatStatus>;
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<ConversationTimelineProps> = ({
@@ -842,6 +865,7 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
onEditUserMessage,
editingMessageId,
savingMessageId,
urlTransform,
}) => {
const shouldRenderStreamInLastSection =
hasStreamOutput && parsedSections.length > 0;
@@ -888,6 +912,7 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
/>
),
)}
@@ -900,6 +925,7 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
subagentStatusOverrides={subagentStatusOverrides}
showInitialPlaceholder={isAwaitingFirstStreamChunk}
retryState={retryState}
urlTransform={urlTransform}
/>
)}
</div>
@@ -913,6 +939,7 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
subagentStatusOverrides={subagentStatusOverrides}
showInitialPlaceholder={isAwaitingFirstStreamChunk}
retryState={retryState}
urlTransform={urlTransform}
/>
)}
</div>
@@ -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<HTMLDivElement | null>;
urlTransform?: UrlTransform;
}
export const AgentDetailView: FC<AgentDetailViewProps> = ({
@@ -154,6 +157,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
handleUnarchiveAgentAction,
handleArchiveAndDeleteWorkspaceAction,
scrollContainerRef,
urlTransform,
}) => {
// Panel/sidebar UI state – purely visual, no data-fetching
// implications.
@@ -267,6 +271,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
savingMessageId={pendingEditMessageId}
urlTransform={urlTransform}
/>
</div>
</div>