Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions site/src/pages/AgentsPage/AgentChatPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,9 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
<ChatSummaryPanel
chatId={agentId}
isVisible={shouldShowSidebar && effectiveSidebarTabId === "summary"}
workspace={workspace}
workspaceAgent={workspaceAgent}
wildcardHostname={wildcardHostname}
/>
);
case "git":
Expand Down
39 changes: 39 additions & 0 deletions site/src/pages/AgentsPage/components/ChatSummary.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
30 changes: 29 additions & 1 deletion site/src/pages/AgentsPage/components/ChatSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { ExternalLinkIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { formatCostMicros } from "#/utils/currency";
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;
Expand All @@ -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<ChatSummaryProps> = ({
Expand All @@ -30,6 +39,7 @@ export const ChatSummary: FC<ChatSummaryProps> = ({
unpricedRequestCount,
showCost,
isSubagent,
previews,
}) => {
const trimmedSummary = summary?.trim();
const hasCost =
Expand All @@ -56,6 +66,24 @@ export const ChatSummary: FC<ChatSummaryProps> = ({
<ChatSummaryRow label="Updated:">
{formatDateTime(updatedAt, DATE_FORMAT.MEDIUM_DATE)}
</ChatSummaryRow>
{previews && previews.length > 0 && (
<ChatSummaryRow label="Preview:">
<div className="flex flex-col">
{previews.map((preview) => (
<a
key={preview.port}
href={preview.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-content-link no-underline hover:underline"
>
{preview.label} ({preview.port})
<ExternalLinkIcon aria-hidden className="size-3 shrink-0" />
</a>
))}
</div>
</ChatSummaryRow>
)}
{showCost && (
<ChatSummaryRow label="Cost:">
{isCostLoading ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
},
};
96 changes: 83 additions & 13 deletions site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<number, string> = 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<ChatSummaryPanelProps> = ({
chatId,
isVisible,
workspace,
workspaceAgent,
wildcardHostname = "",
}) => {
const showCost = Boolean(useFeatureVisibility().aibridge);
const chatQuery = useQuery({ ...chat(chatId), enabled: isVisible });
Expand All @@ -30,19 +52,31 @@ export const ChatSummaryPanel: FC<ChatSummaryPanelProps> = ({
if (chatQuery.isError) {
content = <ErrorAlert error={chatQuery.error} />;
} else if (chatData) {
content = (
<ChatSummary
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={showCost}
isCostLoading={costQuery.isLoading}
costError={costQuery.isError}
/>
);
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) ? (
<ChatSummaryWithPreviews
workspace={workspace}
agent={workspaceAgent}
host={wildcardHostname}
isVisible={isVisible}
{...summaryProps}
/>
) : (
<ChatSummary {...summaryProps} />
);
}

return (
Expand All @@ -51,3 +85,39 @@ export const ChatSummaryPanel: FC<ChatSummaryPanelProps> = ({
</div>
);
};

/**
* 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 <ChatSummary {...summaryProps} previews={previews} />;
};
Loading