Skip to content
Draft
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
@@ -0,0 +1,172 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent } from "storybook/test";
import type {
AIBridgeSessionThreadsResponse,
AIBridgeThread,
} from "#/api/typesGenerated";
import {
MockAIBridgeSessionNetworkCalls,
MockSession,
} from "#/testHelpers/entities";
import { SessionThreadsPageView } from "./SessionThreadsPageView";

// A thread with a prompt and one tool call.
const mockThread: AIBridgeThread = {
id: "thread-1",
prompt: "Summarize the project structure",
model: "claude-opus-4-6",
provider: "anthropic",
credential_kind: "centralized",
credential_hint: "sk-a...efgh",
started_at: "2026-03-09T09:28:15.000Z",
ended_at: "2026-03-09T09:28:47.000Z",
token_usage: {
input_tokens: 1240,
output_tokens: 320,
cache_read_input_tokens: 900,
cache_write_input_tokens: 140,
metadata: {},
},
agentic_actions: [
{
model: "claude-opus-4-6",
token_usage: {
input_tokens: 620,
output_tokens: 160,
cache_read_input_tokens: 450,
cache_write_input_tokens: 70,
metadata: {},
},
thinking: [],
tool_calls: [
{
id: "tool-1",
interception_id: "interception-1",
provider_response_id: "resp-1",
server_url: "http://localhost:3000/mcp",
tool: "list_directory",
injected: false,
input: JSON.stringify({ path: "." }),
metadata: {},
created_at: "2026-03-09T09:28:20.000Z",
},
],
},
],
};

const mockSession: AIBridgeSessionThreadsResponse = {
id: MockSession.id,
initiator: MockSession.initiator,
providers: MockSession.providers,
models: MockSession.models,
metadata: MockSession.metadata,
started_at: MockSession.started_at,
ended_at: MockSession.ended_at,
token_usage_summary: {
input_tokens: 1234,
output_tokens: 4321,
cache_read_input_tokens: 980,
cache_write_input_tokens: 120,
metadata: {},
},
network_calls: { total: 4, blocked: 2 },
network_call_logs: MockAIBridgeSessionNetworkCalls,
threads: [mockThread],
};

const noop = () => {};

const meta: Meta<typeof SessionThreadsPageView> = {
title: "pages/AIBridgePage/SessionThreadsPageView",
component: SessionThreadsPageView,
args: {
session: mockSession,
threads: [mockThread],
loading: false,
hasNextPage: false,
isFetchingNextPage: false,
onFetchNextPage: noop,
isAISessionsEnabled: true,
isAISessionsEntitled: true,
onBackClicked: noop,
},
};

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

export const Default: Story = {};

// Typing a query that matches a prompt keeps the matching thread and drops the
// non-matching one; typing a query that matches a network destination keeps
// only the matching network rows. Clearing the search restores everything.
export const SearchFiltersEvents: Story = {
args: {
threads: [
mockThread,
{
...mockThread,
id: "thread-2",
prompt: "Deploy the service to production",
agentic_actions: [],
},
],
},
play: async ({ canvas }) => {
const input = canvas.getByRole("textbox", {
name: /search session events/i,
});

// Both threads are visible before searching.
await expect(
canvas.getByText("Summarize the project structure"),
).toBeVisible();
await expect(
canvas.getByText("Deploy the service to production"),
).toBeVisible();

// Search by prompt text: only the matching thread stays.
await userEvent.type(input, "deploy");
await expect(
canvas.getByText("Deploy the service to production"),
).toBeVisible();
await expect(
canvas.queryByText("Summarize the project structure"),
).not.toBeInTheDocument();

// Search by network destination: only matching rows stay.
await userEvent.clear(input);
await userEvent.type(input, "npmjs.org");
await canvas.findByText("Network calls (1)");
await expect(
canvas.getByText("https://registry.npmjs.org/lodash"),
).toBeVisible();
await expect(
canvas.queryByText("https://api.github.com/repos/coder/coder"),
).not.toBeInTheDocument();

// Clear: everything returns.
await userEvent.clear(input);
await canvas.findByText("Network calls (4)");
await expect(
canvas.getByText("https://registry.npmjs.org/lodash"),
).toBeVisible();
await expect(
canvas.getByText("Summarize the project structure"),
).toBeVisible();
},
};

// A query that matches nothing shows the dedicated empty state.
export const SearchNoMatches: Story = {
play: async ({ canvas }) => {
const input = canvas.getByRole("textbox", {
name: /search session events/i,
});
await userEvent.type(input, "no-such-event");
await expect(
canvas.getByText("No events match your search."),
).toBeInTheDocument();
},
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { ArrowLeftIcon, InfoIcon } from "lucide-react";
import type { FC, PropsWithChildren } from "react";
import { type FC, type PropsWithChildren, useState } from "react";
import type {
AIBridgeSessionThreadsResponse,
AIBridgeThread,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import { PaywallAIGovernance } from "#/components/Paywall/PaywallAIGovernance";
import { SearchField } from "#/components/SearchField/SearchField";
import {
Tooltip,
TooltipContent,
Expand Down Expand Up @@ -61,6 +62,8 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
isAISessionsEntitled,
onBackClicked,
}) => {
const [searchQuery, setSearchQuery] = useState("");

if (!isAISessionsEntitled) {
return <PaywallAIGovernance />;
}
Expand Down Expand Up @@ -131,15 +134,26 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({
</aside>
<main className="flex-1 min-w-0">
{session ? (
<SessionTimeline
initiator={session.initiator}
threads={threads}
networkCallSummary={session.network_calls}
networkCalls={session.network_call_logs ?? []}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
/>
<>
<SearchField
value={searchQuery}
onChange={setSearchQuery}
onClear={() => setSearchQuery("")}
placeholder="Search prompts, tool calls, and network calls"
aria-label="Search session events"
className="mb-4"
/>
<SessionTimeline
initiator={session.initiator}
threads={threads}
networkCallSummary={session.network_calls}
networkCalls={session.network_call_logs ?? []}
searchQuery={searchQuery}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
/>
</>
) : (
loading && <SessionTimelineSkeleton />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const meta: Meta<typeof SessionTimeline> = {
initiator: MockSession.initiator,
threads: [mockThread],
networkCalls: [],
searchQuery: "",
hasNextPage: false,
isFetchingNextPage: false,
onFetchNextPage: noop,
Expand Down Expand Up @@ -160,6 +161,63 @@ export const MultipleThreads: Story = {
args: { threads: [mockThread, mockThreadLong] },
};

// Filtering to a tool name keeps only the thread whose agentic loop contains a
// matching tool call. The tool name is only visible once the agentic loop is
// expanded, so assert on the thread prompt instead.
export const SearchFiltersThreads: Story = {
args: {
threads: [mockThread, mockThreadLong],
searchQuery: "read_file",
},
play: async ({ canvas }) => {
await expect(
canvas.getByText(
"Please refactor the authentication module so that it uses the new token-based flow we discussed. Make sure to update all the related tests and add inline comments explaining the security rationale for each change.",
),
).toBeInTheDocument();
await expect(
canvas.queryByText("Summarize the project structure"),
).not.toBeInTheDocument();
},
};

// While searching, the network panel header and rows reflect matches only.
// "github.com" appears in two of the mock calls (api.github.com and the DNS
// lookup for api.github.com), so the panel header shows the filtered count.
export const SearchFiltersNetworkCalls: Story = {
args: {
networkCallSummary: { total: 4, blocked: 2 },
networkCalls: MockAIBridgeSessionNetworkCalls,
searchQuery: "npmjs.org",
},
play: async ({ canvas }) => {
await canvas.findByText("Network calls (1)");
await expect(
canvas.getByText("https://registry.npmjs.org/lodash"),
).toBeInTheDocument();
await expect(
canvas.queryByText("https://api.github.com/repos/coder/coder"),
).not.toBeInTheDocument();
},
};

// A query that matches nothing shows a dedicated empty state while keeping the
// session start/end markers.
export const SearchNoMatches: Story = {
args: {
threads: [mockThread, mockThreadLong],
networkCallSummary: { total: 4, blocked: 2 },
networkCalls: MockAIBridgeSessionNetworkCalls,
searchQuery: "no-such-event",
},
play: async ({ canvas }) => {
await expect(
canvas.getByText("No events match your search."),
).toBeInTheDocument();
await expect(canvas.queryByText("Prompt")).not.toBeInTheDocument();
},
};

export const FetchingNextPage: Story = {
args: { hasNextPage: true, isFetchingNextPage: true },
};
Expand Down
Loading
Loading