From bd58431182fbc7a7116e556f554d7a19f7238834 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 12 Aug 2026 09:05:54 +0000 Subject: [PATCH] feat(site/src/pages/AIBridgePage/SessionThreadsPage): add in-session search across event types --- .../SessionThreadsPageView.stories.tsx | 172 ++++++++++++++++++ .../SessionThreadsPageView.tsx | 34 +++- .../SessionTimeline.stories.tsx | 58 ++++++ .../SessionTimeline/SessionTimeline.tsx | 66 ++++++- .../SessionTimeline/sessionSearch.test.ts | 98 ++++++++++ .../SessionTimeline/sessionSearch.ts | 50 +++++ 6 files changed, 458 insertions(+), 20 deletions(-) create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.stories.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.test.ts create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.ts diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.stories.tsx new file mode 100644 index 00000000000..b6a8417c105 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.stories.tsx @@ -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 = { + 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; + +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(); + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index ba12f4eca13..d656ee711c3 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -1,5 +1,5 @@ import { ArrowLeftIcon, InfoIcon } from "lucide-react"; -import type { FC, PropsWithChildren } from "react"; +import { type FC, type PropsWithChildren, useState } from "react"; import type { AIBridgeSessionThreadsResponse, AIBridgeThread, @@ -7,6 +7,7 @@ import type { 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, @@ -61,6 +62,8 @@ export const SessionThreadsPageView: FC = ({ isAISessionsEntitled, onBackClicked, }) => { + const [searchQuery, setSearchQuery] = useState(""); + if (!isAISessionsEntitled) { return ; } @@ -131,15 +134,26 @@ export const SessionThreadsPageView: FC = ({
{session ? ( - + <> + setSearchQuery("")} + placeholder="Search prompts, tool calls, and network calls" + aria-label="Search session events" + className="mb-4" + /> + + ) : ( loading && )} diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx index 8b6a6836a10..f2b7fa439cf 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx @@ -129,6 +129,7 @@ const meta: Meta = { initiator: MockSession.initiator, threads: [mockThread], networkCalls: [], + searchQuery: "", hasNextPage: false, isFetchingNextPage: false, onFetchNextPage: noop, @@ -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 }, }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx index b1214fc84e4..169458428cf 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon, InfoIcon, LoaderIcon } from "lucide-react"; -import { type FC, useEffect, useRef, useState } from "react"; +import { type FC, useEffect, useMemo, useRef, useState } from "react"; import type { AgentFirewallLog, AIBridgeAgenticAction, @@ -25,6 +25,7 @@ import { JsonPrettyPrinter } from "../../JsonPrettyPrinter"; import { AgenticLoopTable } from "./AgenticLoopTable"; import { NetworkCallsTable } from "./NetworkCallsTable"; import { PromptTable } from "./PromptTable"; +import { matchesNetworkCallSearch, matchesThreadSearch } from "./sessionSearch"; import { ToolCallTable } from "./ToolCallTable"; interface ExpandableTextProps { @@ -417,6 +418,11 @@ interface SessionTimelineProps { */ networkCallSummary?: AIBridgeSessionNetworkCallSummary; networkCalls: readonly AgentFirewallLog[]; + /** + * Search query applied across all event types. Non-empty values filter + * threads and network calls to matches (see sessionSearch.ts). + */ + searchQuery: string; hasNextPage: boolean; isFetchingNextPage: boolean; onFetchNextPage: () => void; @@ -427,12 +433,31 @@ export const SessionTimeline: FC = ({ threads, networkCallSummary, networkCalls, + searchQuery, hasNextPage, isFetchingNextPage, onFetchNextPage, }) => { const sentinelRef = useRef(null); + const isSearching = searchQuery.trim() !== ""; + + const filteredThreads = useMemo( + () => threads.filter((thread) => matchesThreadSearch(thread, searchQuery)), + [threads, searchQuery], + ); + + const filteredNetworkCalls = useMemo( + () => + networkCalls.filter((call) => + matchesNetworkCallSearch(call, searchQuery), + ), + [networkCalls, searchQuery], + ); + + const hasAnyMatches = + filteredThreads.length > 0 || filteredNetworkCalls.length > 0; + useEffect(() => { const sentinel = sentinelRef.current; @@ -535,17 +560,30 @@ export const SessionTimeline: FC = ({ {/* left vertical line */}
- {networkCallSummary && ( -
- -
- )} + {networkCallSummary && + (!isSearching || filteredNetworkCalls.length > 0) && ( +
+ !call.allowed, + ).length, + } + : networkCallSummary + } + calls={filteredNetworkCalls} + /> +
+ )} {/* threads */}
- {threads.map((thread) => ( + {filteredThreads.map((thread) => ( = ({ /> ))}
+ {isSearching && !hasAnyMatches && ( +

+ No events match your search. +

+ )} {/* infinite scroll sentinel — sits 200px below the last thread */}
{isFetchingNextPage && ( diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.test.ts b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.test.ts new file mode 100644 index 00000000000..b5adbe6108c --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { AIBridgeThread } from "#/api/typesGenerated"; +import { MockAIBridgeSessionNetworkCalls } from "#/testHelpers/entities"; +import { matchesNetworkCallSearch, matchesThreadSearch } from "./sessionSearch"; + +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", + }, + ], + }, + ], +}; + +describe("matchesThreadSearch", () => { + it("matches prompt text case-insensitively", () => { + expect(matchesThreadSearch(mockThread, "PROJECT")).toBe(true); + expect(matchesThreadSearch(mockThread, "summarize")).toBe(true); + }); + + it("matches tool names", () => { + expect(matchesThreadSearch(mockThread, "list_directory")).toBe(true); + }); + + it("matches tool input JSON", () => { + expect(matchesThreadSearch(mockThread, ". ")).toBe(true); + expect(matchesThreadSearch(mockThread, "path")).toBe(true); + }); + + it("does not match model, provider, or unrelated text", () => { + expect(matchesThreadSearch(mockThread, "claude-opus")).toBe(false); + expect(matchesThreadSearch(mockThread, "anthropic")).toBe(false); + }); + + it("an empty or whitespace query matches everything", () => { + expect(matchesThreadSearch(mockThread, "")).toBe(true); + expect(matchesThreadSearch(mockThread, " ")).toBe(true); + }); + + it("does not match a thread with no prompt when query is specific", () => { + const noPrompt: AIBridgeThread = { ...mockThread, prompt: undefined }; + expect(matchesThreadSearch(noPrompt, "structure")).toBe(false); + }); +}); + +describe("matchesNetworkCallSearch", () => { + it("matches the destination detail case-insensitively", () => { + const call = MockAIBridgeSessionNetworkCalls[0]; + expect(matchesNetworkCallSearch(call, "api.github.com")).toBe(true); + expect(matchesNetworkCallSearch(call, "API.GITHUB.COM")).toBe(true); + }); + + it("does not match method, proto, or matched rule", () => { + const call = MockAIBridgeSessionNetworkCalls[0]; + expect(matchesNetworkCallSearch(call, "POST")).toBe(false); + expect(matchesNetworkCallSearch(call, "allow api.github.com")).toBe(false); + }); + + it("an empty query matches everything", () => { + const call = MockAIBridgeSessionNetworkCalls[0]; + expect(matchesNetworkCallSearch(call, "")).toBe(true); + }); +}); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.ts b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.ts new file mode 100644 index 00000000000..fa079823a29 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.ts @@ -0,0 +1,50 @@ +import type { + AgentFirewallLog, + AIBridgeAgenticAction, + AIBridgeThread, +} from "#/api/typesGenerated"; + +/** + * Pure session-search helpers. Matching is case-insensitive substring + * matching over the strict field scope from AIGOV-462: prompt text, tool + * names, tool input JSON, and network call destinations. + */ + +const normalizeQuery = (query: string): string => query.trim().toLowerCase(); + +const matchesToolCalls = ( + actions: readonly AIBridgeAgenticAction[], + q: string, +) => + actions.some((action) => + action.tool_calls.some( + (call) => + call.tool.toLowerCase().includes(q) || + call.input.toLowerCase().includes(q), + ), + ); + +export const matchesThreadSearch = ( + thread: AIBridgeThread, + query: string, +): boolean => { + const q = normalizeQuery(query); + if (q === "") { + return true; + } + return ( + matchesToolCalls(thread.agentic_actions, q) || + (thread.prompt?.toLowerCase().includes(q) ?? false) + ); +}; + +export const matchesNetworkCallSearch = ( + call: AgentFirewallLog, + query: string, +): boolean => { + const q = normalizeQuery(query); + if (q === "") { + return true; + } + return call.detail.toLowerCase().includes(q); +};