mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user