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

Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
<SessionTimeline
initiator={session.initiator}
threads={threads}
networkCallSummary={session.network_calls}
networkCalls={session.network_call_logs ?? []}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent } from "storybook/test";
import { MockAIBridgeSessionNetworkCalls } from "#/testHelpers/entities";
import { NetworkCallsTable } from "./NetworkCallsTable";

const meta: Meta<typeof NetworkCallsTable> = {
title: "pages/AIBridgePage/NetworkCallsTable",
component: NetworkCallsTable,
args: {
summary: { total: 4, blocked: 2 },
calls: MockAIBridgeSessionNetworkCalls,
},
};

export default meta;
type Story = StoryObj<typeof NetworkCallsTable>;

export const Default: Story = {
Comment thread
EhabY marked this conversation as resolved.
play: async ({ canvas }) => {
await canvas.findByText("Network calls (4)");
await expect(canvas.getAllByText("Allowed")).toHaveLength(2);
await expect(canvas.getAllByText("Blocked")).toHaveLength(2);
await expect(
canvas.getByText("https://registry.npmjs.org/lodash"),
).toBeInTheDocument();
},
};

// The header badge counts every blocked call in the session, so it can exceed
// the number of blocked rows on screen once the list is capped.
export const BlockedBadge: Story = {
args: {
summary: { total: 4, blocked: 9 },
},
play: async ({ canvas }) => {
await expect(
canvas.getByText("Blocked network calls: 9"),
).toBeInTheDocument();
Comment on lines +36 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getByText("Blocked network calls: 9") never matches:
the sr-only span and the 9 are sibling nodes (NetworkCallsTable.tsx:42-46), and the matcher
reads only direct text children. This play function fails. Put the full string in one node, or
use aria-label + getByLabelText. Keep line 39.

await expect(canvas.getAllByText("Blocked")).toHaveLength(2);
},
};

export const NoBlockedCalls: Story = {
args: {
summary: { total: 1, blocked: 0 },
calls: [MockAIBridgeSessionNetworkCalls[0]],
},
play: async ({ canvas }) => {
await canvas.findByText("Network calls (1)");
await expect(canvas.queryByText("Blocked")).not.toBeInTheDocument();
},
};

export const ExpandRow: Story = {
play: async ({ canvas }) => {
await expect(canvas.queryByText("Protocol")).not.toBeInTheDocument();

await userEvent.click(
canvas.getByRole("button", {
name: /https:\/\/api\.github\.com\/repos\/coder\/coder/,
}),
);

await expect(canvas.getByText("Protocol")).toBeInTheDocument();
await expect(canvas.getByText("Matched rule")).toBeInTheDocument();
},
};

export const CollapsePanel: Story = {
play: async ({ canvas }) => {
await expect(
canvas.getByText("https://api.github.com/repos/coder/coder"),
).toBeVisible();

await userEvent.click(canvas.getByText("Network calls (4)"));

await expect(
canvas.queryByText("https://api.github.com/repos/coder/coder"),
).not.toBeInTheDocument();
},
};

export const Empty: Story = {
args: {
summary: { total: 0, blocked: 0 },
calls: [],
},
play: async ({ canvas }) => {
await canvas.findByText("Network calls (0)");
await expect(
canvas.getByText("No network calls were recorded for this session."),
).toBeInTheDocument();
},
};

// A summary total with no rows to show is not a state the server produces. The
// panel still shows a single message rather than claiming both that nothing was
// recorded and that the list was capped.
export const EmptyListWithSummaryTotal: Story = {
args: {
summary: { total: 4, blocked: 2 },
calls: [],
},
play: async ({ canvas }) => {
await expect(
canvas.getByText("No network calls were recorded for this session."),
).toBeInTheDocument();
await expect(
canvas.queryByText(/Showing the first/),
).not.toBeInTheDocument();
},
};

// When the session has more calls than the server returns, the panel notes
// how many are shown.
export const Truncated: Story = {
args: {
summary: { total: 150, blocked: 2 },
calls: MockAIBridgeSessionNetworkCalls,
},
play: async ({ canvas }) => {
await canvas.findByText("Network calls (150)");
await expect(
canvas.getByText(/Showing the first 4 of 150 network calls\./),
).toBeInTheDocument();
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { BanIcon, CheckIcon, ChevronRightIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import type {
AgentFirewallLog,
AIBridgeSessionNetworkCallSummary,
} from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "#/components/Collapsible/Collapsible";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { formatDateTime } from "#/utils/time";

interface NetworkCallsTableProps {
/**
* Drives the header count and blocked badge. Reflects the whole session, so
* its total can exceed the number of rows in `calls`, which is capped
* server-side.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: JSDoc says summary drives the header and badge, but NetworkCallsList (:56) reuses this interface and renders neither.

Something like:

Session-wide totals. total can exceed calls.length, which is capped server-side.

summary: AIBridgeSessionNetworkCallSummary;
calls: readonly AgentFirewallLog[];
}

export const NetworkCallsTable: FC<NetworkCallsTableProps> = ({
summary,
calls,
}) => (
<Collapsible defaultOpen className="border border-solid rounded-md">
<div className="flex items-center justify-between gap-2 px-2 py-1">
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex items-center gap-4 p-1 bg-transparent border-none cursor-pointer text-sm font-normal text-content-secondary"
>
<ChevronRightIcon className="size-3.5 transition-transform group-data-[state=open]:rotate-90" />
<span>Network calls ({summary.total.toLocaleString("en-US")})</span>
Comment thread
SasSwart marked this conversation as resolved.
</button>
</CollapsibleTrigger>
{summary.blocked > 0 && (
<Badge svgSize="xs" className="gap-1 text-content-warning">
<BanIcon className="flex-shrink-0" />
<span className="sr-only">Blocked network calls: </span>
{summary.blocked.toLocaleString("en-US")}
</Badge>
)}
</div>

<CollapsibleContent className="border-0 border-t border-solid">
<NetworkCallsList summary={summary} calls={calls} />
</CollapsibleContent>
</Collapsible>
);

const NetworkCallsList: FC<NetworkCallsTableProps> = ({ summary, calls }) => {
if (calls.length === 0) {
return (
<p className="m-0 px-4 py-3 text-sm font-normal text-content-secondary">
No network calls were recorded for this session.
</p>
);
}

const hiddenCount = summary.total - calls.length;

return (
<>
<ul className="m-0 p-0 list-none">
{calls.map((call) => (
<NetworkCallRow key={call.id} call={call} />
))}
</ul>
{hiddenCount > 0 && (
<p className="m-0 px-4 py-2 text-xs font-normal text-content-secondary border-0 border-t border-solid">
Showing the first {calls.length.toLocaleString("en-US")} of{" "}
{summary.total.toLocaleString("en-US")} network calls.
</p>
)}
</>
);
};

interface NetworkCallRowProps {
call: AgentFirewallLog;
}

const NetworkCallRow: FC<NetworkCallRowProps> = ({ call }) => {
const timestamp = formatDateTime(new Date(call.created_at));

return (
<li className="border-0 border-t border-solid first:border-t-0">
<Collapsible>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex items-center gap-3 w-full px-2 py-2 text-left bg-transparent border-none cursor-pointer hover:bg-surface-secondary"
>
<ChevronRightIcon className="size-3.5 flex-shrink-0 text-content-secondary transition-transform group-data-[state=open]:rotate-90" />
{call.method && (
<Badge size="sm" className="flex-shrink-0 font-mono">
{call.method}
</Badge>
)}
<NetworkCallStatusBadge allowed={call.allowed} />
<span
className="flex-1 min-w-0 truncate font-mono text-xs text-content-primary"
title={call.detail}
>
{call.detail || "N/A"}
</span>
<span className="hidden md:flex items-center gap-2 flex-shrink-0 text-sm font-normal text-content-secondary">
Timestamp
<span className="font-mono text-xs text-content-primary">
{timestamp}
</span>
</span>
</button>
</CollapsibleTrigger>

<CollapsibleContent>
<dl className="flex flex-col gap-2 m-0 px-9 pb-3 text-sm font-normal text-content-secondary">
<NetworkCallDetailRow label="URL">
<span
className="min-w-0 truncate font-mono text-xs text-content-primary"
title={call.detail}
>
{call.detail || "N/A"}
</span>
{call.detail && (
<CopyButton text={call.detail} label="Copy network call URL" />
)}
</NetworkCallDetailRow>
<NetworkCallDetailRow label="Protocol">
<span className="font-mono text-xs text-content-primary">
{call.proto || "N/A"}
</span>
</NetworkCallDetailRow>
<NetworkCallDetailRow label="Matched rule">
<span
className="min-w-0 truncate font-mono text-xs text-content-primary"
title={call.matched_rule ?? undefined}
>
{call.matched_rule ?? "None"}
</span>
</NetworkCallDetailRow>
<NetworkCallDetailRow label="Timestamp">
<span className="font-mono text-xs text-content-primary">
{timestamp}
</span>
</NetworkCallDetailRow>
</dl>
</CollapsibleContent>
</Collapsible>
</li>
);
};

const NetworkCallStatusBadge: FC<{ allowed: boolean }> = ({ allowed }) =>
allowed ? (
<Badge size="sm" svgSize="xs" className="flex-shrink-0 gap-1">
<CheckIcon className="flex-shrink-0" />
Allowed
</Badge>
) : (
<Badge
size="sm"
svgSize="xs"
className="flex-shrink-0 gap-1 text-content-warning"
>
<BanIcon className="flex-shrink-0" />
Blocked
</Badge>
);

interface NetworkCallDetailRowProps {
label: string;
children: ReactNode;
}

const NetworkCallDetailRow: FC<NetworkCallDetailRowProps> = ({
label,
children,
}) => (
<div className="flex items-center justify-between gap-4">
<dt className="shrink-0 whitespace-nowrap">{label}</dt>
<dd className="flex items-center gap-2 m-0 min-w-0">{children}</dd>
</div>
);
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect } from "storybook/test";
import type { AIBridgeThread } from "#/api/typesGenerated";
import { MockSession } from "#/testHelpers/entities";
import {
MockAIBridgeSessionNetworkCalls,
MockSession,
} from "#/testHelpers/entities";
import { SessionTimeline } from "./SessionTimeline";

// A thread with one thinking block and one tool call.
Expand Down Expand Up @@ -124,6 +128,7 @@ const meta: Meta<typeof SessionTimeline> = {
args: {
initiator: MockSession.initiator,
threads: [mockThread],
networkCalls: [],
hasNextPage: false,
isFetchingNextPage: false,
onFetchNextPage: noop,
Expand All @@ -135,6 +140,22 @@ type Story = StoryObj<typeof SessionTimeline>;

export const OneThread: Story = {};

// A summary is present only for sessions that passed through Agent Firewall.
// The panel sits above the threads because its counts are session-scoped
// rather than tied to any one thread.
export const WithNetworkCalls: Story = {
args: {
networkCallSummary: { total: 4, blocked: 2 },
networkCalls: MockAIBridgeSessionNetworkCalls,
},
play: async ({ canvas }) => {
await expect(canvas.getByText("Network calls (4)")).toBeInTheDocument();
await expect(
canvas.getByText("https://api.github.com/repos/coder/coder"),
).toBeInTheDocument();
},
};

export const MultipleThreads: Story = {
args: { threads: [mockThread, mockThreadLong] },
};
Expand Down
Loading
Loading