From f017f5e31b53bcb858190fff49ecc6a31b6d1c92 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Mon, 24 Aug 2026 21:11:41 +0000 Subject: [PATCH] feat(site/src/pages/AgentsPage): show live preview links in chat summary panel --- .../pages/AgentsPage/AgentChatPageView.tsx | 3 + .../components/ChatSummary.stories.tsx | 39 ++++++++ .../AgentsPage/components/ChatSummary.tsx | 30 +++++- .../components/ChatSummaryPanel.stories.tsx | 61 ++++++++++++ .../components/ChatSummaryPanel.tsx | 96 ++++++++++++++++--- 5 files changed, 215 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index b829c5e05bceb..acef24e371b03 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -710,6 +710,9 @@ export const AgentChatPageView: FC = ({ ); case "git": diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index d680df898e7b0..37316af90f09d 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -47,6 +47,45 @@ export const NoSummary: Story = { }, }; +export const WithPreviews: Story = { + args: { + previews: [ + { + label: "Storybook", + port: 6006, + url: "https://6006--main--ws--user.proxy.example.com/", + }, + { + label: "Preview", + port: 8080, + url: "https://8080--main--ws--user.proxy.example.com/", + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Preview:")).toBeInTheDocument(); + const storybookLink = canvas.getByRole("link", { + name: /Storybook \(6006\)/, + }); + await expect(storybookLink).toHaveAttribute( + "href", + "https://6006--main--ws--user.proxy.example.com/", + ); + await expect( + canvas.getByRole("link", { name: /Preview \(8080\)/ }), + ).toBeInTheDocument(); + }, +}; + +export const NoPreviews: Story = { + args: { previews: [] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText("Preview:")).not.toBeInTheDocument(); + }, +}; + // A subagent's summary is its final report, persisted when it // completes, so an empty summary means the agent is still working. export const SubagentSummaryPending: Story = { diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 41ca7328bcf04..b0f4c579a7c93 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -1,3 +1,4 @@ +import { ExternalLinkIcon } from "lucide-react"; import type { FC, ReactNode } from "react"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { formatCostMicros } from "#/utils/currency"; @@ -5,7 +6,14 @@ import { DATE_FORMAT, formatDateTime } from "#/utils/time"; const EMPTY_VALUE = "-"; -interface ChatSummaryProps { +/** A live port-forward link derived from the workspace's listening ports. */ +export interface ChatSummaryPreviewLink { + label: string; + port: number; + url: string; +} + +export interface ChatSummaryProps { summary: string | null; createdAt: string; updatedAt: string; @@ -18,6 +26,7 @@ interface ChatSummaryProps { showCost: boolean; /** Subagent summaries are the agent's final report, persisted when it completes, so the empty state reads as pending rather than absent. */ isSubagent?: boolean; + previews?: readonly ChatSummaryPreviewLink[]; } export const ChatSummary: FC = ({ @@ -30,6 +39,7 @@ export const ChatSummary: FC = ({ unpricedRequestCount, showCost, isSubagent, + previews, }) => { const trimmedSummary = summary?.trim(); const hasCost = @@ -56,6 +66,24 @@ export const ChatSummary: FC = ({ {formatDateTime(updatedAt, DATE_FORMAT.MEDIUM_DATE)} + {previews && previews.length > 0 && ( + +
+ {previews.map((preview) => ( + + {preview.label} ({preview.port}) + + + ))} +
+
+ )} {showCost && ( {isCostLoading ? ( diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index 896ee03d98fd8..c3b19ec967c7b 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -4,6 +4,7 @@ import { expect, spyOn, waitFor, within } from "storybook/test"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { MockChat } from "#/testHelpers/chatEntities"; +import { MockWorkspace, MockWorkspaceAgent } from "#/testHelpers/entities"; import { withDashboardProvider } from "#/testHelpers/storybook"; import { ChatSummaryPanel } from "./ChatSummaryPanel"; @@ -156,3 +157,63 @@ export const GatewayUnavailable: Story = { expect(API.experimental.getChatCost).not.toHaveBeenCalled(); }, }; + +export const WithPreviewLinks: Story = { + args: { + workspace: MockWorkspace, + workspaceAgent: MockWorkspaceAgent, + wildcardHostname: "*.proxy.example.com", + }, + beforeEach: () => { + mockRequests({ summary: "Built the storybook and started a dev server." }); + spyOn(API, "getAgentListeningPorts").mockResolvedValue({ + ports: [ + { process_name: "node", network: "tcp", port: 8080 }, + { process_name: "node", network: "tcp", port: 6006 }, + ], + }); + spyOn(API, "getWorkspaceAgentSharedPorts").mockResolvedValue({ + shares: [], + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Preview:")).toBeInTheDocument(); + }); + const storybookLink = canvas.getByRole("link", { + name: /Storybook \(6006\)/, + }); + expect(storybookLink).toHaveAttribute( + "href", + "http://6006--a-workspace-agent--test-workspace--testuser.proxy.example.com/", + ); + expect( + canvas.getByRole("link", { name: /Preview \(8080\)/ }), + ).toBeInTheDocument(); + }, +}; + +export const NoPreviewWithoutWildcardHost: Story = { + args: { + workspace: MockWorkspace, + workspaceAgent: MockWorkspaceAgent, + wildcardHostname: "", + }, + beforeEach: () => { + mockRequests({ summary: "No wildcard access URL configured." }); + spyOn(API, "getAgentListeningPorts"); + spyOn(API, "getWorkspaceAgentSharedPorts"); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByText("No wildcard access URL configured."), + ).toBeInTheDocument(); + }); + expect(canvas.queryByText("Preview:")).not.toBeInTheDocument(); + expect(API.getAgentListeningPorts).not.toHaveBeenCalled(); + expect(API.getWorkspaceAgentSharedPorts).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx index 7f4e0b0bedef4..905473441d889 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -1,20 +1,42 @@ import type { FC, ReactNode } from "react"; import { useQuery } from "react-query"; import { chat, chatCost } from "#/api/queries/chats"; +import type { Workspace, WorkspaceAgent } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; +import { + canShowPortForwarding, + usePortsData, +} from "#/modules/resources/usePortsData"; +import { portForwardURL } from "#/utils/portForward"; import { getChatCostTreeID } from "./ChatConversation/chatHelpers"; +import type { ChatSummaryPreviewLink, ChatSummaryProps } from "./ChatSummary"; import { ChatSummary } from "./ChatSummary"; +/** + * Ports whose purpose is recognizable from the port number alone. Everything + * else falls back to a generic "Preview" label. + */ +const KNOWN_PORT_LABELS: ReadonlyMap = new Map([ + [6006, "Storybook"], +]); + type ChatSummaryPanelProps = { chatId: string; /** Gate reads on tab visibility so the chat and cost queries don't run while the tab is hidden. */ isVisible: boolean; + workspace?: Workspace; + workspaceAgent?: WorkspaceAgent; + /** Wildcard proxy hostname used to build preview links; empty when unconfigured. */ + wildcardHostname?: string; }; export const ChatSummaryPanel: FC = ({ chatId, isVisible, + workspace, + workspaceAgent, + wildcardHostname = "", }) => { const showCost = Boolean(useFeatureVisibility().aibridge); const chatQuery = useQuery({ ...chat(chatId), enabled: isVisible }); @@ -30,19 +52,31 @@ export const ChatSummaryPanel: FC = ({ if (chatQuery.isError) { content = ; } else if (chatData) { - content = ( - - ); + const summaryProps: ChatSummaryProps = { + summary: chatData.summary, + isSubagent: Boolean(chatData.parent_chat_id), + createdAt: chatData.created_at, + updatedAt: chatData.updated_at, + costMicros: costQuery.data?.total_cost_micros, + unpricedRequestCount: costQuery.data?.unpriced_request_count, + showCost, + isCostLoading: costQuery.isLoading, + costError: costQuery.isError, + }; + content = + workspace && + workspaceAgent && + canShowPortForwarding(workspaceAgent, wildcardHostname) ? ( + + ) : ( + + ); } return ( @@ -51,3 +85,39 @@ export const ChatSummaryPanel: FC = ({ ); }; + +/** + * Wraps ChatSummary with live preview links built from the agent's listening + * ports, using the same ports queries and URL scheme as the workspace pill. + */ +const ChatSummaryWithPreviews: FC< + ChatSummaryProps & { + workspace: Workspace; + agent: WorkspaceAgent; + host: string; + isVisible: boolean; + } +> = ({ workspace, agent, host, isVisible, ...summaryProps }) => { + const portsData = usePortsData( + workspace, + agent, + isVisible && agent.status === "connected", + ); + + const previews: ChatSummaryPreviewLink[] = (portsData.listeningPorts ?? []) + .toSorted((a, b) => a.port - b.port) + .map((port) => ({ + label: KNOWN_PORT_LABELS.get(port.port) ?? "Preview", + port: port.port, + url: portForwardURL( + host, + port.port, + agent.name, + workspace.name, + workspace.owner_name, + portsData.protocol, + ), + })); + + return ; +};