From d753830fea2b3e38a1aec19f270b75cb472d3e05 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Wed, 22 Jul 2026 15:58:58 +0000 Subject: [PATCH 1/3] feat(site): show network calls list on AI session detail Render the per-call network calls as a collapsible panel above the session threads. Each row shows the method, allowed/blocked status, URL, and timestamp, and expands to show the protocol, matched rule, and full detail. The panel is omitted when the session did not pass through Agent Firewall. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SessionThreadsPageView.tsx | 2 + .../NetworkCallsTable.stories.tsx | 111 +++++++++++ .../SessionTimeline/NetworkCallsTable.tsx | 186 ++++++++++++++++++ .../SessionTimeline.stories.tsx | 15 +- .../SessionTimeline/SessionTimeline.tsx | 18 ++ site/src/testHelpers/entities.ts | 44 +++++ 6 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index f2dee3a81ec1a..ba12f4eca139c 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -134,6 +134,8 @@ export const SessionThreadsPageView: FC = ({ = { + title: "pages/AIBridgePage/NetworkCallsTable", + component: NetworkCallsTable, + args: { + summary: { total: 4, blocked: 2 }, + calls: MockAIBridgeSessionNetworkCalls, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvas }) => { + // Header shows the total from the summary. + await canvas.findByText("Network calls (4)"); + // Both allowed and blocked calls render with their status labels. + await expect(canvas.getAllByText("Allowed")).toHaveLength(2); + await expect(canvas.getAllByText("Blocked")).toHaveLength(2); + // A blocked destination from the mock is listed. + await expect( + canvas.getByText("https://registry.npmjs.org/lodash"), + ).toBeInTheDocument(); + }, +}; + +// The blocked-count badge in the header reflects the summary, not the number +// of rendered rows. +export const BlockedBadge: Story = { + play: async ({ canvas }) => { + const header = canvas.getByText("Network calls (4)").closest("div"); + if (!header) { + throw new Error("network calls header not found"); + } + await expect(within(header).getByText("2")).toBeInTheDocument(); + }, +}; + +export const NoBlockedCalls: Story = { + args: { + summary: { total: 1, blocked: 0 }, + calls: [MockAIBridgeSessionNetworkCalls[0]], + }, + play: async ({ canvas }) => { + await canvas.findByText("Network calls (1)"); + // With zero blocked calls the warning badge is omitted. + await expect(canvas.queryByText("Blocked")).not.toBeInTheDocument(); + }, +}; + +// Expanding a row reveals its detail fields. +export const ExpandRow: Story = { + play: async ({ canvas }) => { + await expect(canvas.queryByText("Protocol")).not.toBeInTheDocument(); + + const rowButtons = canvas.getAllByRole("button", { expanded: false }); + // The first row button toggles the first call's detail open. + await userEvent.click(rowButtons[0]); + + await expect(canvas.getByText("Protocol")).toBeInTheDocument(); + await expect(canvas.getByText("Matched rule")).toBeInTheDocument(); + }, +}; + +// Collapsing the panel header hides the list of calls. +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(); + }, +}; + +// 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(); + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx new file mode 100644 index 0000000000000..dd82f98ba55e6 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx @@ -0,0 +1,186 @@ +import { BanIcon, CheckIcon, ChevronRightIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; +import type { + AIBridgeSessionNetworkCall, + AIBridgeSessionNetworkCallSummary, +} from "#/api/typesGenerated"; +import { Badge } from "#/components/Badge/Badge"; +import { CopyButton } from "#/components/CopyButton/CopyButton"; +import { cn } from "#/utils/cn"; +import { formatDateTime } from "#/utils/time"; + +interface NetworkCallsTableProps { + // summary drives the header count and blocked badge. It reflects the whole + // session, so its total can exceed the number of rows in `calls`, which is + // capped server-side. + summary: AIBridgeSessionNetworkCallSummary; + calls: readonly AIBridgeSessionNetworkCall[]; +} + +export const NetworkCallsTable: FC = ({ + summary, + calls, +}) => { + const [isOpen, setIsOpen] = useState(true); + const hiddenCount = summary.total - calls.length; + + return ( +
+
+ + {summary.blocked > 0 && ( + + + {summary.blocked.toLocaleString("en-US")} + + )} +
+ + {isOpen && ( +
+ {calls.length === 0 ? ( +

+ No network calls were recorded for this session. +

+ ) : ( +
    + {calls.map((call) => ( + + ))} +
+ )} + {hiddenCount > 0 && ( +

+ Showing the first {calls.length.toLocaleString("en-US")} of{" "} + {summary.total.toLocaleString("en-US")} network calls. +

+ )} +
+ )} +
+ ); +}; + +interface NetworkCallRowProps { + call: AIBridgeSessionNetworkCall; +} + +const NetworkCallRow: FC = ({ call }) => { + const [isOpen, setIsOpen] = useState(false); + const timestamp = formatDateTime(new Date(call.created_at)); + + return ( +
  • + + + {isOpen && ( +
    + + + {call.detail || "N/A"} + + {call.detail && ( + + )} + + + + {call.proto || "N/A"} + + + + + {call.matched_rule ?? "None"} + + + + + {timestamp} + + +
    + )} +
  • + ); +}; + +const NetworkCallStatusBadge: FC<{ allowed: boolean }> = ({ allowed }) => + allowed ? ( + + + Allowed + + ) : ( + + + Blocked + + ); + +interface NetworkCallDetailRowProps { + label: string; + children: ReactNode; +} + +const NetworkCallDetailRow: FC = ({ + label, + children, +}) => ( +
    +
    {label}
    +
    {children}
    +
    +); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx index 7beb04b4c6728..fc8506762e1ee 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx @@ -1,6 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; 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. @@ -124,6 +127,7 @@ const meta: Meta = { args: { initiator: MockSession.initiator, threads: [mockThread], + networkCalls: [], hasNextPage: false, isFetchingNextPage: false, onFetchNextPage: noop, @@ -135,6 +139,15 @@ type Story = StoryObj; export const OneThread: Story = {}; +// The network calls panel is rendered above the threads when the session +// passed through Agent Firewall. +export const WithNetworkCalls: Story = { + args: { + networkCallSummary: { total: 4, blocked: 2 }, + networkCalls: MockAIBridgeSessionNetworkCalls, + }, +}; + export const MultipleThreads: Story = { args: { threads: [mockThread, mockThreadLong] }, }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx index 7154e1ca7a567..13e6d44dceb71 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -2,6 +2,8 @@ import { ChevronRightIcon, InfoIcon, LoaderIcon } from "lucide-react"; import { type FC, useEffect, useRef, useState } from "react"; import type { AIBridgeAgenticAction, + AIBridgeSessionNetworkCall, + AIBridgeSessionNetworkCallSummary, AIBridgeThread, MinimalUser, } from "#/api/typesGenerated"; @@ -21,6 +23,7 @@ import { cn } from "#/utils/cn"; import { docs } from "#/utils/docs"; import { JsonPrettyPrinter } from "../../JsonPrettyPrinter"; import { AgenticLoopTable } from "./AgenticLoopTable"; +import { NetworkCallsTable } from "./NetworkCallsTable"; import { PromptTable } from "./PromptTable"; import { ToolCallTable } from "./ToolCallTable"; @@ -408,6 +411,10 @@ const ThreadItem: FC = ({ thread, initiator }) => { interface SessionTimelineProps { initiator: MinimalUser; threads: readonly AIBridgeThread[]; + // networkCallSummary is nil when the session did not pass through Agent + // Firewall, in which case the network calls panel is not rendered. + networkCallSummary?: AIBridgeSessionNetworkCallSummary; + networkCalls: readonly AIBridgeSessionNetworkCall[]; hasNextPage: boolean; isFetchingNextPage: boolean; onFetchNextPage: () => void; @@ -416,6 +423,8 @@ interface SessionTimelineProps { export const SessionTimeline: FC = ({ initiator, threads, + networkCallSummary, + networkCalls, hasNextPage, isFetchingNextPage, onFetchNextPage, @@ -524,6 +533,15 @@ export const SessionTimeline: FC = ({ {/* left vertical line */}
    + {/* network calls panel, session-scoped, above the threads */} + {networkCallSummary && ( +
    + +
    + )} {/* threads */}
    {threads.map((thread) => ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bc3ef896c5d3b..f2e8cc8dfde32 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5556,6 +5556,50 @@ export const MockSession: TypesGen.AIBridgeSession = { last_active_at: "2026-03-09T10:28:15.03152Z", }; +export const MockAIBridgeSessionNetworkCalls: TypesGen.AIBridgeSessionNetworkCall[] = + [ + { + id: "netcall-1", + sequence_number: 1, + proto: "http", + method: "POST", + detail: "https://api.github.com/repos/coder/coder", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:16.000Z", + }, + { + id: "netcall-2", + sequence_number: 2, + proto: "http", + method: "GET", + detail: "https://registry.npmjs.org/lodash", + allowed: false, + matched_rule: undefined, + created_at: "2026-03-09T09:28:17.000Z", + }, + { + id: "netcall-3", + sequence_number: 3, + proto: "http", + method: "POST", + detail: "https://hooks.slack.com/services/T01", + allowed: false, + matched_rule: undefined, + created_at: "2026-03-09T09:28:18.000Z", + }, + { + id: "netcall-4", + sequence_number: 4, + proto: "dns", + method: "A", + detail: "api.github.com", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:19.000Z", + }, + ]; + export const MockAIProviderOpenAI: TypesGen.AIProvider = { id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01", type: "openai", From 3af5588388f7a4a52645571c8019dfc378669580 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 3 Aug 2026 11:20:19 +0000 Subject: [PATCH 2/3] refactor(site): use AgentFirewallLog type for network calls The API now returns firewall log entries via the shared AgentFirewallLog type, replacing the bespoke AIBridgeSessionNetworkCall type. Update the components and mock data to match. --- .../SessionTimeline/NetworkCallsTable.tsx | 6 +- .../SessionTimeline/SessionTimeline.tsx | 4 +- site/src/testHelpers/entities.ts | 89 ++++++++++--------- 3 files changed, 51 insertions(+), 48 deletions(-) diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx index dd82f98ba55e6..8587cee6251d2 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx @@ -1,7 +1,7 @@ import { BanIcon, CheckIcon, ChevronRightIcon } from "lucide-react"; import { type FC, type ReactNode, useState } from "react"; import type { - AIBridgeSessionNetworkCall, + AgentFirewallLog, AIBridgeSessionNetworkCallSummary, } from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; @@ -14,7 +14,7 @@ interface NetworkCallsTableProps { // session, so its total can exceed the number of rows in `calls`, which is // capped server-side. summary: AIBridgeSessionNetworkCallSummary; - calls: readonly AIBridgeSessionNetworkCall[]; + calls: readonly AgentFirewallLog[]; } export const NetworkCallsTable: FC = ({ @@ -75,7 +75,7 @@ export const NetworkCallsTable: FC = ({ }; interface NetworkCallRowProps { - call: AIBridgeSessionNetworkCall; + call: AgentFirewallLog; } const NetworkCallRow: FC = ({ call }) => { diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx index 13e6d44dceb71..37e81b6947819 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -1,8 +1,8 @@ import { ChevronRightIcon, InfoIcon, LoaderIcon } from "lucide-react"; import { type FC, useEffect, useRef, useState } from "react"; import type { + AgentFirewallLog, AIBridgeAgenticAction, - AIBridgeSessionNetworkCall, AIBridgeSessionNetworkCallSummary, AIBridgeThread, MinimalUser, @@ -414,7 +414,7 @@ interface SessionTimelineProps { // networkCallSummary is nil when the session did not pass through Agent // Firewall, in which case the network calls panel is not rendered. networkCallSummary?: AIBridgeSessionNetworkCallSummary; - networkCalls: readonly AIBridgeSessionNetworkCall[]; + networkCalls: readonly AgentFirewallLog[]; hasNextPage: boolean; isFetchingNextPage: boolean; onFetchNextPage: () => void; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f2e8cc8dfde32..fa94d86f66c2d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5556,49 +5556,52 @@ export const MockSession: TypesGen.AIBridgeSession = { last_active_at: "2026-03-09T10:28:15.03152Z", }; -export const MockAIBridgeSessionNetworkCalls: TypesGen.AIBridgeSessionNetworkCall[] = - [ - { - id: "netcall-1", - sequence_number: 1, - proto: "http", - method: "POST", - detail: "https://api.github.com/repos/coder/coder", - allowed: true, - matched_rule: "allow api.github.com", - created_at: "2026-03-09T09:28:16.000Z", - }, - { - id: "netcall-2", - sequence_number: 2, - proto: "http", - method: "GET", - detail: "https://registry.npmjs.org/lodash", - allowed: false, - matched_rule: undefined, - created_at: "2026-03-09T09:28:17.000Z", - }, - { - id: "netcall-3", - sequence_number: 3, - proto: "http", - method: "POST", - detail: "https://hooks.slack.com/services/T01", - allowed: false, - matched_rule: undefined, - created_at: "2026-03-09T09:28:18.000Z", - }, - { - id: "netcall-4", - sequence_number: 4, - proto: "dns", - method: "A", - detail: "api.github.com", - allowed: true, - matched_rule: "allow api.github.com", - created_at: "2026-03-09T09:28:19.000Z", - }, - ]; +export const MockAIBridgeSessionNetworkCalls: TypesGen.AgentFirewallLog[] = [ + { + id: "netcall-1", + session_id: "firewall-session-1", + sequence_number: 1, + proto: "http", + method: "POST", + detail: "https://api.github.com/repos/coder/coder", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:16.000Z", + }, + { + id: "netcall-2", + session_id: "firewall-session-1", + sequence_number: 2, + proto: "http", + method: "GET", + detail: "https://registry.npmjs.org/lodash", + allowed: false, + matched_rule: null, + created_at: "2026-03-09T09:28:17.000Z", + }, + { + id: "netcall-3", + session_id: "firewall-session-1", + sequence_number: 3, + proto: "http", + method: "POST", + detail: "https://hooks.slack.com/services/T01", + allowed: false, + matched_rule: null, + created_at: "2026-03-09T09:28:18.000Z", + }, + { + id: "netcall-4", + session_id: "firewall-session-1", + sequence_number: 4, + proto: "dns", + method: "A", + detail: "api.github.com", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:19.000Z", + }, +]; export const MockAIProviderOpenAI: TypesGen.AIProvider = { id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01", From c88a18252c058dea3c75b0cf576d229c217161ee Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Mon, 3 Aug 2026 11:54:09 +0000 Subject: [PATCH 3/3] refactor(site): use Radix Collapsible for network calls table Removes hand-rolled open/close state and CSS-driven chevron rotation in favor of the shared Collapsible primitive, matching the pattern used elsewhere in the codebase. --- .../NetworkCallsTable.stories.tsx | 50 ++-- .../SessionTimeline/NetworkCallsTable.tsx | 221 +++++++++--------- .../SessionTimeline.stories.tsx | 12 +- .../SessionTimeline/SessionTimeline.tsx | 7 +- site/src/testHelpers/entities.ts | 93 ++++---- 5 files changed, 206 insertions(+), 177 deletions(-) diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx index 88990b3a2730e..de612d7036cd7 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within } from "storybook/test"; +import { expect, userEvent } from "storybook/test"; import { MockAIBridgeSessionNetworkCalls } from "#/testHelpers/entities"; import { NetworkCallsTable } from "./NetworkCallsTable"; @@ -17,27 +17,26 @@ type Story = StoryObj; export const Default: Story = { play: async ({ canvas }) => { - // Header shows the total from the summary. await canvas.findByText("Network calls (4)"); - // Both allowed and blocked calls render with their status labels. await expect(canvas.getAllByText("Allowed")).toHaveLength(2); await expect(canvas.getAllByText("Blocked")).toHaveLength(2); - // A blocked destination from the mock is listed. await expect( canvas.getByText("https://registry.npmjs.org/lodash"), ).toBeInTheDocument(); }, }; -// The blocked-count badge in the header reflects the summary, not the number -// of rendered rows. +// 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 }) => { - const header = canvas.getByText("Network calls (4)").closest("div"); - if (!header) { - throw new Error("network calls header not found"); - } - await expect(within(header).getByText("2")).toBeInTheDocument(); + await expect( + canvas.getByText("Blocked network calls: 9"), + ).toBeInTheDocument(); + await expect(canvas.getAllByText("Blocked")).toHaveLength(2); }, }; @@ -48,26 +47,25 @@ export const NoBlockedCalls: Story = { }, play: async ({ canvas }) => { await canvas.findByText("Network calls (1)"); - // With zero blocked calls the warning badge is omitted. await expect(canvas.queryByText("Blocked")).not.toBeInTheDocument(); }, }; -// Expanding a row reveals its detail fields. export const ExpandRow: Story = { play: async ({ canvas }) => { await expect(canvas.queryByText("Protocol")).not.toBeInTheDocument(); - const rowButtons = canvas.getAllByRole("button", { expanded: false }); - // The first row button toggles the first call's detail open. - await userEvent.click(rowButtons[0]); + 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(); }, }; -// Collapsing the panel header hides the list of calls. export const CollapsePanel: Story = { play: async ({ canvas }) => { await expect( @@ -95,6 +93,24 @@ export const Empty: Story = { }, }; +// 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 = { diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx index 8587cee6251d2..d6a9cdc38b05a 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx @@ -1,18 +1,24 @@ import { BanIcon, CheckIcon, ChevronRightIcon } from "lucide-react"; -import { type FC, type ReactNode, useState } from "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 { cn } from "#/utils/cn"; import { formatDateTime } from "#/utils/time"; interface NetworkCallsTableProps { - // summary drives the header count and blocked badge. It reflects the whole - // session, so its total can exceed the number of rows in `calls`, which is - // capped server-side. + /** + * 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. + */ summary: AIBridgeSessionNetworkCallSummary; calls: readonly AgentFirewallLog[]; } @@ -20,57 +26,58 @@ interface NetworkCallsTableProps { export const NetworkCallsTable: FC = ({ summary, calls, -}) => { - const [isOpen, setIsOpen] = useState(true); - const hiddenCount = summary.total - calls.length; - - return ( -
    -
    +}) => ( + +
    + - {summary.blocked > 0 && ( - - - {summary.blocked.toLocaleString("en-US")} - - )} -
    - - {isOpen && ( -
    - {calls.length === 0 ? ( -

    - No network calls were recorded for this session. -

    - ) : ( -
      - {calls.map((call) => ( - - ))} -
    - )} - {hiddenCount > 0 && ( -

    - Showing the first {calls.length.toLocaleString("en-US")} of{" "} - {summary.total.toLocaleString("en-US")} network calls. -

    - )} -
    + + {summary.blocked > 0 && ( + + + Blocked network calls: + {summary.blocked.toLocaleString("en-US")} + )}
    + + + + + +); + +const NetworkCallsList: FC = ({ summary, calls }) => { + if (calls.length === 0) { + return ( +

    + No network calls were recorded for this session. +

    + ); + } + + const hiddenCount = summary.total - calls.length; + + return ( + <> +
      + {calls.map((call) => ( + + ))} +
    + {hiddenCount > 0 && ( +

    + Showing the first {calls.length.toLocaleString("en-US")} of{" "} + {summary.total.toLocaleString("en-US")} network calls. +

    + )} + ); }; @@ -79,76 +86,72 @@ interface NetworkCallRowProps { } const NetworkCallRow: FC = ({ call }) => { - const [isOpen, setIsOpen] = useState(false); const timestamp = formatDateTime(new Date(call.created_at)); return (
  • - - - {isOpen && ( -
    - + + +
    - )} + + + + +
    + + + {call.detail || "N/A"} + + {call.detail && ( + + )} + + + + {call.proto || "N/A"} + + + + + {call.matched_rule ?? "None"} + + + + + {timestamp} + + +
    +
    +
  • ); }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx index fc8506762e1ee..8b6a6836a10b9 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; import type { AIBridgeThread } from "#/api/typesGenerated"; import { MockAIBridgeSessionNetworkCalls, @@ -139,13 +140,20 @@ type Story = StoryObj; export const OneThread: Story = {}; -// The network calls panel is rendered above the threads when the session -// passed through Agent Firewall. +// 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 = { diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx index 37e81b6947819..b1214fc84e4ed 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -411,8 +411,10 @@ const ThreadItem: FC = ({ thread, initiator }) => { interface SessionTimelineProps { initiator: MinimalUser; threads: readonly AIBridgeThread[]; - // networkCallSummary is nil when the session did not pass through Agent - // Firewall, in which case the network calls panel is not rendered. + /** + * Undefined when the session did not pass through Agent Firewall, in which + * case the network calls panel is not rendered. + */ networkCallSummary?: AIBridgeSessionNetworkCallSummary; networkCalls: readonly AgentFirewallLog[]; hasNextPage: boolean; @@ -533,7 +535,6 @@ export const SessionTimeline: FC = ({ {/* left vertical line */}
    - {/* network calls panel, session-scoped, above the threads */} {networkCallSummary && (