feat(site/src/pages/AIBridgePage/SessionThreadsPage): add in-session search across event types - #28054
Conversation
…search across event types
|
test |
…h-across-all-event-types
|
Smoke-tested in browser |
Documentation CheckThis PR adds a user-facing search input to the AI Gateway Session detail view that filters the timeline (prompts, tool calls, and Agent Firewall network activity). That view is documented, but the docs don't mention the search capability yet. Updates Needed
Automated review via Coder Agents |
There was a problem hiding this comment.
Pull request overview
Adds client-side, in-session search for the AI Gateway session detail timeline, filtering both session threads (prompts and tool calls) and Agent Firewall network activity based on a single query string.
Changes:
- Introduces a
SearchFieldto the session threads page and threads the query down into the timeline component. - Adds filtering logic in
SessionTimelinefor threads and network calls, including adjusted network summary counts while searching and a no-match empty state. - Adds pure search helpers (
sessionSearch.ts) with unit tests, plus Storybook interaction stories covering search behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx | Applies query-driven filtering across threads and network calls, updates network summary while searching, and renders an empty state. |
| site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx | Adds Storybook interaction coverage for filtered threads, filtered network calls, and no-match state. |
| site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.ts | Implements pure helper functions for matching threads and firewall logs against a query. |
| site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/sessionSearch.test.ts | Adds vitest unit tests for the pure search helper behavior. |
| site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx | Adds the search input and passes searchQuery to SessionTimeline. |
| site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.stories.tsx | Adds Storybook interaction coverage for page-level search, clear, and no-match behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| {isSearching && !hasAnyMatches && ( | ||
| <p | ||
| className="m-0 py-4 text-sm font-normal text-content-secondary" | ||
| role="status" | ||
| > | ||
| No events match your search. | ||
| </p> | ||
| )} |
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 13 findings (3 P2, 3 P3, 5 Nit, 2 Note), REQUEST_CHANGES. Review Finding inventoryFinding inventoryFindings
Contested and acknowledgedNone. Round logRound 1Netero + panel (14 reviewers). 3 P2, 3 P3, 5 Nit, 5 Note. Reviewed against 6765731..801cbc0. Cross-check disposition notes:
Findings dropped/absorbed during cross-check:
Pre-existing but tracked: CRF-1 (em-dash) predates this PR at About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
In-session search lands as a clean, well-tested addition: pure predicates in sessionSearch.ts with negative-case unit tests, Storybook interaction coverage for the timeline and page view, the shared SearchField primitive reused instead of hand-rolled, and dogfood screenshots for every state. Nice work on the scope discipline (AIGOV-461 explicitly deferred, boundary-events dependency documented).
Requesting changes on three P2 findings that undercut the audit workflow this feature exists to serve, plus process notes on comments and coverage.
Findings this round: 3 P2, 3 P3, 5 Nit, 4 Note (16 total).
The P2s concentrate on the same theme: what the feature promises vs what it delivers when a real session hits real limits.
- [CRF-3] During search,
NetworkCallsTablegets a synthesizedsummarywhosetotalequals the filtered count, sohiddenCount = summary.total - calls.lengthis always 0 and the "Showing the first X of Y network calls" truncation footer disappears.enterprise/coderd/aibridge.go:54capsnetwork_call_logsat 1000 per session and explicitly warns "Sessions past the cap need pagination to see the remainder"; the footer is the sole UI signal of that. A destination that lives only in the truncated tail now returns "Network calls (0)" orNo events match your search.with no indication the search was incomplete. Six reviewers converged (Hisoka P2, Nami P2, Pariston P2, Mafuuu P3, Luffy P3, Zoro P3). - [CRF-4] The pagination effect deps include
isFetchingNextPage, so each fetch remounts theIntersectionObserver, whose initial callback re-fires as soon as the sentinel intersects. Before this PR the sentinel was buried under many rendered rows; after this PR, a zero-match filter renders just a one-line paragraph above the sentinel, so the observer always fires on remount andfetchNextPagecascades untilhasNextPageis false. On a 400-thread session that is ~20 back-to-back page loads per keystroke that hits nothing. Skipping the fetch whileisSearchingalso resolves the visible UX side of CRF-2. - [CRF-5] The placeholder ("Search prompts, tool calls, and network calls"), the aria-label ("Search session events"), and the
SessionTimelineProps.searchQueryJSDoc ("across all event types") all describe a broader search than the code implements.matchesNetworkCallSearchlooks atcall.detailonly;method,proto,matched_rule,model,providerare all visible on the page and untouched by search. The tests lock this in (POSTandallow api.github.comare asserted to not match). Either narrow the copy to the real scope or widen the filter.
Code paths to know about while reviewing the fix:
- The truncation contract is documented on
codersdk.AIBridgeSessionThreadsResponse.NetworkCallLogsinsite/src/api/typesGenerated.ts:209-215and the enterprise handlerenterprise/coderd/aibridge.go:50-55. PreservingnetworkCallSummary.totalwhile searching (and lettingNetworkCallsListrender the note with a search-adjusted phrasing) restores the signal without a server round-trip. - Nami's option (a) fix for CRF-4 (skip
fetchNextPage()whileisSearching) also closes Netero's CRF-2 (empty-state visible mid-pagination) because there is no in-flight pagination to compete with.
Process observations, not blockers:
- Commit body is empty; sibling
featcommits undersite/src/pages/AIBridgePage/(a60f393, 3f3fd1c, f17d488) each carry a paragraph.git logandgit blameshow only the subject line without one, so move the first two paragraphs of the PR description into the commit body. mockThread: AIBridgeThreadis now duplicated acrossSessionTimeline/SessionTimeline.stories.tsx,SessionThreadsPageView.stories.tsx, andsessionSearch.test.ts, with the same tool call and near-identical fields. A sharedMockAIBridgeThreadintestHelpers/entities.tswould let each site override only what it needs.- Comment quality across the new stories is a pattern, not a per-line lapse. Roughly a dozen in-scope comments restate the code beneath ("Both threads are visible before searching" above two
getByTextassertions; "Search by prompt text: only the matching thread stays" above auserEvent.typeand a matching assertion). Keep only the traps and the whys the code cannot state itself. coder-tasksflaggeddocs/ai-coder/ai-gateway/audit.mdfor a search-input update (Session detail navigating + forensic-audit steps). Worth doing in this PR since the docs already describe the surface.
Bisky pinned down the coverage gap with a mutation test: "I replaced threads.filter((thread) => matchesThreadSearch(thread, searchQuery)) with threads.filter(() => true) and re-ran the file. Every search story in SessionTimeline.stories.tsx failed as expected, except this one, which still passed." That's CRF-6; one line fixes it.
🤖 This review was automatically generated with Coder Agents.
| No events match your search. | ||
| </p> | ||
| )} | ||
| {/* infinite scroll sentinel — sits 200px below the last thread */} |
There was a problem hiding this comment.
Nit [CRF-1] Em-dash (U+2014) in the infinite scroll sentinel comment. (Netero)
{/* infinite scroll sentinel — sits 200px below the last thread */}
Predates this PR (blame ed908ed019, 2026-06-22) but sits in a file this diff modifies. make lint/emdash catches it; the AGENTS.md rule forbids the character in comments. Replace — with a period or restructure the sentence.
🤖
| /> | ||
| ))} | ||
| </div> | ||
| {isSearching && !hasAnyMatches && ( |
There was a problem hiding this comment.
P3 [CRF-2] No events match your search. renders while hasNextPage may still be true, so the definitive negative is actually a mid-pagination snapshot. (Netero)
{isSearching && !hasAnyMatches && ( <p ... role="status"> No events match your search. </p> )}
isSearching && !hasAnyMatches fires whenever the currently-loaded arrays yield no filter hits. While hasNextPage, the sentinel is still rendered below the message, the IntersectionObserver keeps firing onFetchNextPage, and matches can appear on the next batch. In the meantime the user reads a definitive negative that will lie if pagination stalls. Related to CRF-4: the fix suggested there (skip fetchNextPage() while isSearching) also resolves this, since then there is no next batch to wait for. Alternative: soften to "No matches so far." while hasNextPage, or hold the message until pagination drains.
🤖
| // the matching rows. Otherwise the session-scoped summary is | ||
| // preserved so the server truncation note stays accurate. | ||
| summary={ | ||
| isSearching |
There was a problem hiding this comment.
P2 [CRF-3] Search-time summary override suppresses the server-truncation footer on the Agent Firewall audit surface; a search over a large session looks exhaustive when it is not. (Hisoka P2, Nami P2, Pariston P2, Mafuuu P3, Luffy P3, Zoro P3)
summary={ isSearching ? { total: filteredNetworkCalls.length, blocked: filteredNetworkCalls.filter( (call) => !call.allowed, ).length, } : networkCallSummary } calls={filteredNetworkCalls}
network_call_logs is capped at 1000 per session server-side (enterprise/coderd/aibridge.go:54; comment: "Sessions past the cap need pagination to see the remainder"), and NetworkCallsList uses hiddenCount = summary.total - calls.length to render "Showing the first X of Y network calls" (NetworkCallsTable.tsx:65-79). That footer is the only UI signal the list is truncated.
While searching, this PR replaces both sides of the identity with the same filtered subset, so hiddenCount is always 0 and the footer never renders during a search. Consequences:
- Session with, say, 2,000 network calls (server returned the first 1,000). A query for a destination that lives in the truncated tail returns
Network calls (0)orNo events match your search.(the network panel is hidden by thefilteredNetworkCalls.length > 0guard at line 564). The operator concludes the call never happened. - Same session, a query that hits 3 rows in the loaded prefix. Header reads
Network calls (3), no truncation note, no indication the true match count could be many multiples higher.
This is the failure mode the truncation footer was added to prevent, on the audit surface where the answer to "did this destination ever appear?" has to be right. Keep networkCallSummary (or the true network_call_logs length) as summary.total during search so NetworkCallsList still emits the footer, or render a search-specific note like "N matches within the first M of Y loaded network calls." blocked can stay derived from filteredNetworkCalls.
🤖
| 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(() => { |
There was a problem hiding this comment.
P2 [CRF-4] The pre-existing pagination effect at line 461 (deps [hasNextPage, isFetchingNextPage, onFetchNextPage]) tears down and re-creates the IntersectionObserver on every isFetchingNextPage transition. IntersectionObserver.observe() delivers an initial notification with the current intersection state, so each re-attach fires the callback. When these new filteredThreads and filteredNetworkCalls empty out, the sentinel sits at the top of the panel and is always intersecting the 200px rootMargin. (Hisoka P2, Nami P3)
const filteredThreads = useMemo(...); const filteredNetworkCalls = useMemo(...); const hasAnyMatches = filteredThreads.length > 0 || filteredNetworkCalls.length > 0;
Sequence with a zero-match query on a paginated session:
- Effect runs; sentinel is inside the 200px
rootMarginbecause the only visible content is the "No events match your search." paragraph; callback fires ->onFetchNextPage(). isFetchingNextPageflips true; effect re-runs; new observer fires immediately; guard fails, no fetch.- Page returns;
isFetchingNextPageflips false; effect re-runs; new observer fires; sentinel still intersecting;onFetchNextPage()fires again. - Loop until
hasNextPageis false.
SESSION_THREADS_INFINITE_PAGE_SIZE = 20 (api/queries/aiBridge.ts), so a 400-thread session runs ~20 fetches back-to-back for a typo that yields no matches. Cheapest fix: skip fetchNextPage() while isSearching. Server pagination cannot help a client-only search anyway, and this also resolves the visible UX side of CRF-2 (the empty state no longer competes with in-flight pagination).
None of the new stories combine hasNextPage: true with an active searchQuery, so the play runs do not surface this. Add e.g. SearchNoMatchesWithNextPage: { hasNextPage: true, searchQuery: "no-such-event" } to make the cascade visible in Storybook.
🤖
| value={searchQuery} | ||
| onChange={setSearchQuery} | ||
| onClear={() => setSearchQuery("")} | ||
| placeholder="Search prompts, tool calls, and network calls" |
There was a problem hiding this comment.
P2 [CRF-5] Placeholder and aria-label promise a search scope the code does not deliver, and the visible columns tell the same lie. (Leorio P2, Pariston P3, Gon P2, Melody Note, Luffy Note)
placeholder="Search prompts, tool calls, and network calls"
aria-label="Search session events"
The placeholder sits above NetworkCallsTable, which renders method, proto, and matched_rule as visible columns (NetworkCallsTable.tsx:100,136,144). matchesNetworkCallSearch only checks call.detail, and the test suite locks that in: matchesNetworkCallSearch(call, "POST") -> false, matchesNetworkCallSearch(call, "allow api.github.com") -> false (sessionSearch.test.ts:88-91), and matchesThreadSearch rejects "claude-opus" and "anthropic" on the thread side. So an operator staring at a POST cell or an allow api.github.com matched-rule cell types the exact string they can see, gets "No events match your search.", and concludes the search is broken. It isn't broken; it lies about its scope.
Same drift shows up in the SessionTimelineProps.searchQuery JSDoc ("Search query applied across all event types") which the caller can't distinguish from the placeholder.
Pick one and mean it. Either widen scope so visible columns are searchable (add method, proto, matched_rule to matchesNetworkCallSearch; model, provider, server_url, etc. on the thread side), or narrow the copy to what actually matches, e.g. placeholder="Search prompt text, tool names, tool inputs, and network destinations" (and update the aria-label and JSDoc to match). The current aria-label ("Search session events") is the only hint a screen-reader user gets and is even vaguer than the placeholder.
🤖
| // 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 |
There was a problem hiding this comment.
Nit [CRF-9] Stale story comment: describes a "github.com" scenario but the story searches "npmjs.org". (Melody, Gon)
// 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.
The story sets searchQuery: "npmjs.org" (line 191) and asserts Network calls (1) (line 194) with the npmjs row. Only netcall-2 (https://registry.npmjs.org/lodash) contains that substring; the count=2 github.com scenario the comment narrates does not run. Either rewrite the comment to match npmjs.org, or switch the story to searchQuery: "github.com" and assert Network calls (2).
🤖
| /** | ||
| * 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. | ||
| */ |
There was a problem hiding this comment.
Nit [CRF-10] Module doc cites Linear ticket AIGOV-462 in source. (Leorio, Gon)
/** * 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. */
grep -rn "AIGOV-" site/src returns exactly this hit; no other site/src file references Linear tickets. External readers cannot open it, and the sentence already enumerates the four fields. Drop from AIGOV-462; the ticket ref already lives in the PR description as Refs https://linear.app/codercom/issue/AIGOV-462.
🤖
| <SearchField | ||
| value={searchQuery} | ||
| onChange={setSearchQuery} | ||
| onClear={() => setSearchQuery("")} |
There was a problem hiding this comment.
Nit [CRF-11] onClear prop duplicates SearchField's built-in default. (Nami, Zoro)
onClear={() => setSearchQuery("")}
SearchField.handleClear already falls back to onChange("") when onClear is omitted (components/SearchField/SearchField.tsx). Every other in-tree caller (FilterPopover.tsx, ModuleSelectStep.tsx, MultiUserSelect.tsx, Chart.tsx) omits the prop. Drop the line; keep only onChange={setSearchQuery}.
🤖
| isAISessionsEntitled, | ||
| onBackClicked, | ||
| }) => { | ||
| const [searchQuery, setSearchQuery] = useState(""); |
There was a problem hiding this comment.
Note [CRF-12] searchQuery state is not keyed by sessionId, so a query carries across direct URL-to-URL navigation between session detail pages. (Pariston)
const [searchQuery, setSearchQuery] = useState("");
The route /ai-gateway/sessions/:sessionId reuses this component, so the useState("") persists across sessionId changes. Recording this as a note because the in-app Back button unmounts the page; only direct URL-to-URL nav hits it, and clearing the field is one click. If session-to-session linking is added later, either add key={sessionId} on the timeline or reset searchQuery in an effect keyed to sessionId.
🤖
| * 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. | ||
| */ |
There was a problem hiding this comment.
Note [CRF-13] Docstring calls the network scope "network call destinations" but the code matches against call.detail. (Zoro)
MockAIBridgeSessionNetworkCalls shows detail: "https://api.github.com/repos/coder/coder" for HTTP boundary logs; that means a search for /repos/ matches, which is wider than "destination" implies. Either tighten the comment to "network call detail (URL for HTTP, host for DNS)", or parse the URL host-only if the ticket really means destination. Prefer the doc fix unless the ticket says otherwise.
🤖
Adds a search input to the AI Gateway session detail view that filters the session timeline across all event types: Bridge prompts, tool calls, and Agent Firewall network activity.
The search matches prompt text, tool names, tool input JSON, and network call destinations. While a query is active, the timeline shows only matching threads and network rows, the network panel header/blocked counts reflect the matches, and a "No events match your search." empty state appears when nothing matches. Clearing the search restores the full session.
Dogfood screenshots
Validated end to end against a local dev instance with a real provider and live AI session data.
Login:
Sessions list:
Session detail (initial state):
Searching "authentication" keeps the matching thread:
A query with no match shows the empty state:
Clearing the search restores the thread:
Non-matching session shows the empty state too:
Implementation plan
SessionThreadsPageView.tsxholds thesearchQuerystate and renders a reusableSearchField; it passes the query to the timeline.SessionTimeline.tsxfiltersthreadsandnetworkCallsviauseMemo, derives the network-panel summary counts from matching rows while searching (preserving the server-scoped summary/truncation note when idle), and shows the empty state.sessionSearch.tsadds purematchesThreadSearch/matchesNetworkCallSearchhelpers with the strict field scope from the issue.sessionSearch.tsis a pure-logic file, so its vitest coverage is appropriate per frontend conventions.Notes:
mainas a separateNetworkCallsTablepanel above the threads (the unified inline-timeline refactor AIGOV-459 was canceled). This search filters the same entity arrays the timeline renders, so it works now and continues to work once boundary events move inline.Refs https://linear.app/codercom/issue/AIGOV-462
Coder Agents generated. Please review and mark ready when satisfied.