From 9e18b633a8b39492491eeb48c168092220416074 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 13 May 2026 12:26:31 +0100 Subject: [PATCH 1/2] feat: add search filter to agent tracer Add a live search input under the tabs in the tracer panel that filters trace history entries and steps in realtime. The query matches against userMessage, sessionId, planId, step display names, and stringified step content. When the query matches entry metadata, all steps in that entry remain visible; otherwise only matching steps are shown. The counter reports matched steps out of total steps across all visible entries. Also adds display names and icons for OutputEvaluationStep and BeforeReasoningIterationStep, and renames ReasoningStep's display name from "Output Evaluation" to "Reasoning". --- test/webview/AgentTracer.helpers.test.tsx | 160 ++++++++++++++- test/webview/AgentTracer.test.tsx | 193 ++++++++++++++++++ .../components/AgentTracer/AgentTracer.css | 76 +++++++ .../components/AgentTracer/AgentTracer.tsx | 183 ++++++++++++++--- 4 files changed, 584 insertions(+), 28 deletions(-) diff --git a/test/webview/AgentTracer.helpers.test.tsx b/test/webview/AgentTracer.helpers.test.tsx index 951dc695..32b182da 100644 --- a/test/webview/AgentTracer.helpers.test.tsx +++ b/test/webview/AgentTracer.helpers.test.tsx @@ -6,6 +6,9 @@ import { applyHistorySelection, buildTimelineItems, getStepData, + matchesTraceFilter, + entryMetadataMatches, + stepMatchesFilter, type TraceHistoryEntry } from '../../webview/src/components/AgentTracer/AgentTracer'; @@ -249,7 +252,7 @@ describe('AgentTracer helpers', () => { const items = buildTimelineItems(trace, () => {}); expect(items).toHaveLength(1); - expect(items[0].label).toBe('Output Evaluation'); + expect(items[0].label).toBe('Reasoning'); expect(items[0].description).toBe('SMALL_TALK'); }); @@ -694,4 +697,159 @@ describe('AgentTracer helpers', () => { items[0].onClick?.(); expect(indices).toEqual([0]); }); + + describe('matchesTraceFilter', () => { + const buildEntry = (overrides: Partial = {}): TraceHistoryEntry => ({ + storageKey: 'agent', + agentId: 'agent', + sessionId: 'session', + planId: 'plan', + userMessage: 'Hello world', + trace: { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { type: 'UserInputStep', message: 'Check the weather' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } } + ] + }, + ...overrides + }); + + it('returns true for an empty or whitespace query', () => { + const entry = buildEntry(); + expect(matchesTraceFilter(entry, '')).toBe(true); + expect(matchesTraceFilter(entry, ' ')).toBe(true); + }); + + it('matches against the user message case-insensitively', () => { + expect(matchesTraceFilter(buildEntry(), 'HELLO')).toBe(true); + expect(matchesTraceFilter(buildEntry(), 'world')).toBe(true); + }); + + it('matches against the step type display name', () => { + expect(matchesTraceFilter(buildEntry(), 'User Input')).toBe(true); + expect(matchesTraceFilter(buildEntry(), 'action executed')).toBe(true); + }); + + it('matches against nested JSON content of a step', () => { + expect(matchesTraceFilter(buildEntry(), 'lookup_account')).toBe(true); + expect(matchesTraceFilter(buildEntry(), 'A123')).toBe(true); + }); + + it('returns false when there is no match', () => { + expect(matchesTraceFilter(buildEntry(), 'nonexistent_value')).toBe(false); + }); + + it('returns false when the trace has no plan and message does not match', () => { + const entry = buildEntry({ + userMessage: undefined, + trace: { type: 'PlanSuccessResponse', planId: 'plan', sessionId: 'session', plan: [] } + }); + expect(matchesTraceFilter(entry, 'anything')).toBe(false); + }); + }); + + describe('entryMetadataMatches', () => { + const buildEntry = (overrides: Partial = {}): TraceHistoryEntry => ({ + storageKey: 'agent', + agentId: 'agent', + sessionId: 'abc-session-123', + planId: 'xyz-plan-456', + userMessage: 'Find weather', + trace: { type: 'PlanSuccessResponse', planId: 'xyz-plan-456', sessionId: 'abc-session-123', plan: [] }, + ...overrides + }); + + it('returns true for empty query', () => { + expect(entryMetadataMatches(buildEntry(), '')).toBe(true); + expect(entryMetadataMatches(buildEntry(), ' ')).toBe(true); + }); + + it('matches against userMessage', () => { + expect(entryMetadataMatches(buildEntry(), 'weather')).toBe(true); + }); + + it('matches against sessionId', () => { + expect(entryMetadataMatches(buildEntry(), 'abc-session')).toBe(true); + }); + + it('matches against planId', () => { + expect(entryMetadataMatches(buildEntry(), 'xyz-plan')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(entryMetadataMatches(buildEntry(), 'WEATHER')).toBe(true); + expect(entryMetadataMatches(buildEntry(), 'ABC-SESSION')).toBe(true); + }); + + it('returns false when nothing matches', () => { + expect(entryMetadataMatches(buildEntry(), 'nonexistent')).toBe(false); + }); + + it('returns false when fields are missing and no match', () => { + const entry = buildEntry({ userMessage: undefined, sessionId: '', planId: '' }); + expect(entryMetadataMatches(entry, 'anything')).toBe(false); + }); + }); + + describe('stepMatchesFilter', () => { + it('returns true for empty query', () => { + expect(stepMatchesFilter({ type: 'UserInputStep' }, '')).toBe(true); + }); + + it('matches by step display name', () => { + expect(stepMatchesFilter({ type: 'FunctionStep' }, 'action executed')).toBe(true); + expect(stepMatchesFilter({ type: 'OutputEvaluationStep' }, 'output evaluation')).toBe(true); + }); + + it('matches by raw step type when not in display map', () => { + expect(stepMatchesFilter({ type: 'CustomUnknownStep' }, 'CustomUnknown')).toBe(true); + }); + + it('matches against nested JSON content', () => { + const step = { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }; + expect(stepMatchesFilter(step, 'lookup_account')).toBe(true); + expect(stepMatchesFilter(step, 'A123')).toBe(true); + }); + + it('returns false when nothing matches', () => { + expect(stepMatchesFilter({ type: 'UserInputStep', message: 'hello' }, 'goodbye')).toBe(false); + }); + }); + + describe('buildTimelineItems with filter', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'p', + sessionId: 's', + plan: [ + { type: 'UserInputStep', message: 'hello' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }, + { type: 'ReasoningStep', reason: 'topic_selector' } + ] + }; + + it('returns all items when filter is empty', () => { + const items = buildTimelineItems(trace, () => {}); + expect(items).toHaveLength(3); + }); + + it('filters timeline items by step content', () => { + const items = buildTimelineItems(trace, () => {}, 'A123'); + expect(items).toHaveLength(1); + expect(items[0].label).toContain('Action Executed'); + }); + + it('filters timeline items by step display name', () => { + const items = buildTimelineItems(trace, () => {}, 'reasoning'); + expect(items).toHaveLength(1); + }); + + it('returns empty array when no steps match', () => { + const items = buildTimelineItems(trace, () => {}, 'no_match_xyz'); + expect(items).toHaveLength(0); + }); + }); }); diff --git a/test/webview/AgentTracer.test.tsx b/test/webview/AgentTracer.test.tsx index f951fc56..b13b8507 100644 --- a/test/webview/AgentTracer.test.tsx +++ b/test/webview/AgentTracer.test.tsx @@ -626,4 +626,197 @@ describe('AgentTracer', () => { expect(screen.getByRole('button', { name: /first/i })).toHaveAttribute('aria-expanded', 'false'); expect(screen.getByRole('button', { name: /second/i })).toHaveAttribute('aria-expanded', 'false'); }); + + describe('search filter', () => { + const renderWithEntries = () => { + render(); + const trace1 = { + type: 'PlanSuccessResponse', + planId: 'plan-1', + sessionId: 'session-1', + plan: [{ type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }] + }; + const trace2 = { + type: 'PlanSuccessResponse', + planId: 'plan-2', + sessionId: 'session-2', + plan: [{ type: 'UserInputStep', message: 'check the weather' }] + }; + dispatchMessage('traceHistory', { + entries: [ + { storageKey: 'agent', agentId: 'agent', planId: 'plan-1', sessionId: 'session-1', userMessage: 'Find account info', trace: trace1 }, + { storageKey: 'agent', agentId: 'agent', planId: 'plan-2', sessionId: 'session-2', userMessage: 'What is the weather', trace: trace2 } + ] + }); + }; + + it('renders the filter input when trace history is present', () => { + renderWithEntries(); + expect(screen.getByLabelText(/Filter trace history/i)).toBeInTheDocument(); + }); + + it('filters entries by user message text', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'weather' } }); + + expect(screen.queryByText('Find account info')).not.toBeInTheDocument(); + expect(screen.getByText('What is the weather')).toBeInTheDocument(); + expect(screen.getByText('1 of 2')).toBeInTheDocument(); + }); + + it('filters by JSON content within steps', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'A123' } }); + + expect(screen.getByText('Find account info')).toBeInTheDocument(); + expect(screen.queryByText('What is the weather')).not.toBeInTheDocument(); + }); + + it('shows empty-state message when nothing matches', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'no_match_xyz' } }); + + expect(screen.getByText(/No traces match/i)).toBeInTheDocument(); + expect(screen.getByText(/0 of 2/)).toBeInTheDocument(); + }); + + it('clears the filter when the clear button is clicked', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'weather' } }); + expect(screen.queryByText('Find account info')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/Clear filter/i)); + expect(input.value).toBe(''); + expect(screen.getByText('Find account info')).toBeInTheDocument(); + expect(screen.getByText('What is the weather')).toBeInTheDocument(); + }); + + it('filters by sessionId', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'session-1' } }); + + expect(screen.getByText('Find account info')).toBeInTheDocument(); + expect(screen.queryByText('What is the weather')).not.toBeInTheDocument(); + }); + + it('filters by planId', () => { + renderWithEntries(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'plan-2' } }); + + expect(screen.queryByText('Find account info')).not.toBeInTheDocument(); + expect(screen.getByText('What is the weather')).toBeInTheDocument(); + }); + + it('does not show the counter when the filter is empty', () => { + renderWithEntries(); + expect(screen.queryByText(/of \d+/)).not.toBeInTheDocument(); + }); + + it('does not show the clear button when the filter is empty', () => { + renderWithEntries(); + expect(screen.queryByLabelText(/Clear filter/i)).not.toBeInTheDocument(); + }); + + describe('step-level filtering and counting', () => { + const renderMultiStep = () => { + render(); + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan-A', + sessionId: 'session-A', + plan: [ + { type: 'UserInputStep', message: 'hello' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }, + { type: 'ReasoningStep', reason: 'topic_selection' }, + { type: 'OutputEvaluationStep', data: { score: 0.9 } } + ] + }; + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-A', + sessionId: 'session-A', + userMessage: 'My question', + trace + } + ] + }); + }; + + it('counts total steps when filter is empty', () => { + renderMultiStep(); + expect(screen.queryByText(/of 4/)).not.toBeInTheDocument(); + }); + + it('counts only matching steps when query matches step content', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'A123' } }); + + expect(screen.getByText('1 of 4')).toBeInTheDocument(); + }); + + it('counts all steps in entry when query matches entry metadata', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'My question' } }); + + expect(screen.getByText('4 of 4')).toBeInTheDocument(); + }); + + it('counts all steps when query matches sessionId', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'session-A' } }); + + expect(screen.getByText('4 of 4')).toBeInTheDocument(); + }); + + it('hides non-matching steps inside the timeline', () => { + renderMultiStep(); + + // before filtering, all 4 step types should appear in the timeline + expect(screen.getAllByText(/User Input/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Action Executed/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Reasoning/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Output Evaluation/i).length).toBeGreaterThan(0); + + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'A123' } }); + + // only the FunctionStep should remain visible + expect(screen.getAllByText(/Action Executed/i).length).toBeGreaterThan(0); + expect(screen.queryByText(/User Input/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Output Evaluation/i)).not.toBeInTheDocument(); + }); + + it('keeps all steps visible when query matches entry metadata', () => { + renderMultiStep(); + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'My question' } }); + + expect(screen.getAllByText(/User Input/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Action Executed/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Output Evaluation/i).length).toBeGreaterThan(0); + }); + }); + }); }); diff --git a/webview/src/components/AgentTracer/AgentTracer.css b/webview/src/components/AgentTracer/AgentTracer.css index c9644aec..ee9894d8 100644 --- a/webview/src/components/AgentTracer/AgentTracer.css +++ b/webview/src/components/AgentTracer/AgentTracer.css @@ -66,6 +66,82 @@ body.vscode-high-contrast .tracer-loading .loading-spinner { padding-bottom: 16px; } +.trace-filter { + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px 8px; + flex-shrink: 0; +} + +.trace-filter__input-wrapper { + position: relative; + flex: 1; + display: flex; + align-items: center; +} + +.trace-filter__input { + width: 100%; + padding: 6px 28px 6px 8px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 3px; + font-size: 13px; + font-family: var(--vscode-font-family); + outline: none; +} + +.trace-filter__input--with-count { + padding-right: 70px; +} + +.trace-filter__input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.trace-filter__input:focus { + border-color: var(--vscode-focusBorder); +} + +.trace-filter__clear { + position: absolute; + right: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: none; + border-radius: 2px; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.trace-filter__clear:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.trace-filter__count { + position: absolute; + right: 28px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + pointer-events: none; +} + +.trace-filter__empty { + padding: 16px 12px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + text-align: center; +} + .trace-history-selector { width: 100%; flex-shrink: 0; diff --git a/webview/src/components/AgentTracer/AgentTracer.tsx b/webview/src/components/AgentTracer/AgentTracer.tsx index f27795d5..3fa6bbf8 100644 --- a/webview/src/components/AgentTracer/AgentTracer.tsx +++ b/webview/src/components/AgentTracer/AgentTracer.tsx @@ -100,6 +100,61 @@ export const selectHistoryEntry = (entries: TraceHistoryEntry[], index: number): return entries[index].trace ?? null; }; +export const stepMatchesFilter = (step: any, query: string): boolean => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return true; + } + const stepType = step?.type || step?.stepType || ''; + const displayName = STEP_DISPLAY_NAMES[stepType] || stepType; + if (displayName && displayName.toLowerCase().includes(trimmed)) { + return true; + } + try { + if (JSON.stringify(step).toLowerCase().includes(trimmed)) { + return true; + } + } catch { + // ignore stringify failures (e.g. circular refs) + } + return false; +}; + +export const entryMetadataMatches = (entry: TraceHistoryEntry, query: string): boolean => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return true; + } + if (entry.userMessage && entry.userMessage.toLowerCase().includes(trimmed)) { + return true; + } + if (entry.sessionId && entry.sessionId.toLowerCase().includes(trimmed)) { + return true; + } + if (entry.planId && entry.planId.toLowerCase().includes(trimmed)) { + return true; + } + return false; +}; + +export const matchesTraceFilter = (entry: TraceHistoryEntry, query: string): boolean => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return true; + } + + if (entryMetadataMatches(entry, trimmed)) { + return true; + } + + const plan = entry.trace?.plan; + if (!Array.isArray(plan)) { + return false; + } + + return plan.some(step => stepMatchesFilter(step, trimmed)); +}; + export const applyHistorySelection = ( entries: TraceHistoryEntry[], preferredIndex: number | null @@ -129,7 +184,9 @@ const STEP_DISPLAY_NAMES: Record = { VariableUpdateStep: 'Variable Update', TransitionStep: 'Subagent Transition', BeforeReasoningStep: 'Before Reasoning', - ReasoningStep: 'Output Evaluation', + BeforeReasoningIterationStep: 'Before Reasoning Iteration', + ReasoningStep: 'Reasoning', + OutputEvaluationStep: 'Output Evaluation', PlannerResponseStep: 'Agent Response', UpdateTopicStep: 'Subagent Selected', FunctionStep: 'Action Executed' @@ -145,7 +202,9 @@ const STEP_ICONS: Record = { VariableUpdateStep: 'symbol-namespace', TransitionStep: 'arrow-right', BeforeReasoningStep: 'checklist', - ReasoningStep: 'search', + BeforeReasoningIterationStep: 'checklist', + ReasoningStep: 'lightbulb', + OutputEvaluationStep: 'search', PlannerResponseStep: 'agent', UpdateTopicStep: 'tag', FunctionStep: 'action' @@ -242,13 +301,16 @@ const getStepDescription = (step: any): string | undefined => { export const buildTimelineItems = ( traceData: PlanSuccessResponse | null, - onSelect: (index: number) => void + onSelect: (index: number) => void, + filterQuery?: string ): TimelineItemProps[] => { if (!traceData || !traceData.plan) { return []; } - return traceData.plan.map((step: any, index: number) => { + const trimmedFilter = filterQuery?.trim() ?? ''; + + const items = traceData.plan.map((step: any, index: number) => { const stepType = step.type || step.stepType || ''; const stepName = step.name || step.label || step.description || ''; @@ -283,9 +345,18 @@ export const buildTimelineItems = ( label, description, icon, - onClick: hasData ? () => onSelect(index) : undefined - }; + onClick: hasData ? () => onSelect(index) : undefined, + _step: step + } as TimelineItemProps & { _step: any }; }); + + if (!trimmedFilter) { + return items.map(({ _step, ...item }) => item); + } + + return items + .filter(item => stepMatchesFilter(item._step, trimmedFilter)) + .map(({ _step, ...item }) => item); }; export const getStepData = (traceData: PlanSuccessResponse | null, selectedStepIndex: number | null): string | null => { @@ -350,6 +421,7 @@ const AgentTracer: React.FC = ({ const [isResizing, setIsResizing] = useState(false); const [traceHistory, setTraceHistory] = useState([]); const [expandedPlanIds, setExpandedPlanIds] = useState>(new Set()); + const [filterQuery, setFilterQuery] = useState(''); const handleRowExpandedChange = useCallback((planId: string, expanded: boolean) => { setExpandedPlanIds(prev => { @@ -490,6 +562,25 @@ const AgentTracer: React.FC = ({ const shouldShowPlaceholder = !hasTraceHistory; const selectedStepData = getStepData(traceData, selectedStepIndex); + const trimmedFilter = filterQuery.trim(); + const filteredEntries = trimmedFilter + ? traceHistory + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => matchesTraceFilter(entry, trimmedFilter)) + : traceHistory.map((entry, index) => ({ entry, index })); + const hasFilterMatches = filteredEntries.length > 0; + + const totalStepCount = traceHistory.reduce((sum, entry) => sum + (entry.trace?.plan?.length ?? 0), 0); + const matchedStepCount = trimmedFilter + ? filteredEntries.reduce((sum, { entry }) => { + const plan = entry.trace?.plan ?? []; + if (entryMetadataMatches(entry, trimmedFilter)) { + return sum + plan.length; + } + return sum + plan.filter((step: any) => stepMatchesFilter(step, trimmedFilter)).length; + }, 0) + : totalStepCount; + // Handle panel resize const handleResizeStart = (e: React.MouseEvent) => { e.preventDefault(); @@ -529,28 +620,66 @@ const AgentTracer: React.FC = ({ ) : hasTraceHistory ? (
-
- {traceHistory.map((entry, index) => { - const { message } = formatHistoryParts(entry, index); - const timelineItems = buildTimelineItems(entry.trace, stepIndex => - handleRowStepSelect(index, stepIndex) - ); - - return ( - handleRowExpandedChange(entry.planId, expanded)} - onOpenJson={() => handleRowOpenJson(entry)} - timelineItems={timelineItems} - message={message} - selectedStepIndex={selectedHistoryIndex === index ? selectedStepIndex ?? undefined : undefined} - /> - ); - })} +
+
+ setFilterQuery(e.target.value)} + aria-label="Filter trace history" + /> + {trimmedFilter && ( + + {matchedStepCount} of {totalStepCount} + + )} + {filterQuery && ( + + )} +
+ {hasFilterMatches ? ( +
+ {filteredEntries.map(({ entry, index }) => { + const { message } = formatHistoryParts(entry, index); + const metadataMatches = !!trimmedFilter && entryMetadataMatches(entry, trimmedFilter); + const stepFilter = metadataMatches ? '' : trimmedFilter; + const timelineItems = buildTimelineItems( + entry.trace, + stepIndex => handleRowStepSelect(index, stepIndex), + stepFilter + ); + + return ( + handleRowExpandedChange(entry.planId, expanded)} + onOpenJson={() => handleRowOpenJson(entry)} + timelineItems={timelineItems} + message={message} + selectedStepIndex={selectedHistoryIndex === index ? selectedStepIndex ?? undefined : undefined} + /> + ); + })} +
+ ) : ( +
No traces match "{trimmedFilter}"
+ )}
) : shouldShowPlaceholder ? ( Date: Wed, 13 May 2026 12:37:24 +0100 Subject: [PATCH 2/2] fix: preserve tracer step highlight under active filter Translate the stored selectedStepIndex from the original plan position into the filtered timeline's compacted position before passing it to TraceHistoryRow, so the highlight follows the selected step when the filter is active. If the selected step is filtered out, no item is highlighted. Also clear the filter query when traceHistory or sessionStarted resets the panel state, so a stale filter cannot hide a fresh batch. --- test/webview/AgentTracer.helpers.test.tsx | 42 ++++++ test/webview/AgentTracer.test.tsx | 139 ++++++++++++++++++ .../components/AgentTracer/AgentTracer.tsx | 38 ++++- 3 files changed, 218 insertions(+), 1 deletion(-) diff --git a/test/webview/AgentTracer.helpers.test.tsx b/test/webview/AgentTracer.helpers.test.tsx index 32b182da..bb1a1170 100644 --- a/test/webview/AgentTracer.helpers.test.tsx +++ b/test/webview/AgentTracer.helpers.test.tsx @@ -9,6 +9,7 @@ import { matchesTraceFilter, entryMetadataMatches, stepMatchesFilter, + translateStepIndexToFiltered, type TraceHistoryEntry } from '../../webview/src/components/AgentTracer/AgentTracer'; @@ -819,6 +820,47 @@ describe('AgentTracer helpers', () => { }); }); + describe('translateStepIndexToFiltered', () => { + const plan = [ + { type: 'UserInputStep', message: 'hello' }, + { type: 'FunctionStep', function: { name: 'lookup_account', input: { accountId: 'A123' } } }, + { type: 'ReasoningStep', reason: 'topic_selection' }, + { type: 'OutputEvaluationStep', data: { score: 0.9 } } + ]; + + it('returns undefined when index is null', () => { + expect(translateStepIndexToFiltered(plan, null, '')).toBeUndefined(); + }); + + it('returns the original index when filter is empty', () => { + expect(translateStepIndexToFiltered(plan, 2, '')).toBe(2); + expect(translateStepIndexToFiltered(plan, 2, ' ')).toBe(2); + }); + + it('returns the position of the original index in the filtered list', () => { + // Filtering for "Reasoning" matches the ReasoningStep at original index 2. + // After filtering only that step survives, so its filtered index is 0. + expect(translateStepIndexToFiltered(plan, 2, 'topic_selection')).toBe(0); + }); + + it('translates correctly when the original index is the second match in the filtered list', () => { + // "step" matches multiple types. The OutputEvaluationStep at original index 3 + // would be the 4th in a typical filter; for a more controlled case use a query that + // matches indices 1 and 3 only. + const result = translateStepIndexToFiltered(plan, 3, 'OutputEvaluationStep'); + expect(result).toBe(0); // only step 3 matches "OutputEvaluationStep" by raw type + }); + + it('returns undefined when the original index does not match the filter', () => { + // Filter for "A123" only matches index 1; index 0 does not survive. + expect(translateStepIndexToFiltered(plan, 0, 'A123')).toBeUndefined(); + }); + + it('returns undefined when the plan is missing', () => { + expect(translateStepIndexToFiltered(undefined, 0, '')).toBeUndefined(); + }); + }); + describe('buildTimelineItems with filter', () => { const trace = { type: 'PlanSuccessResponse', diff --git a/test/webview/AgentTracer.test.tsx b/test/webview/AgentTracer.test.tsx index b13b8507..886047c1 100644 --- a/test/webview/AgentTracer.test.tsx +++ b/test/webview/AgentTracer.test.tsx @@ -817,6 +817,145 @@ describe('AgentTracer', () => { expect(screen.getAllByText(/Action Executed/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/Output Evaluation/i).length).toBeGreaterThan(0); }); + + const findTimelineItem = (label: RegExp): Element => { + const labels = document.querySelectorAll('.vscode-timeline-item__label'); + for (const labelEl of Array.from(labels)) { + if (label.test(labelEl.textContent ?? '')) { + const item = labelEl.closest('.vscode-timeline-item'); + if (item) return item; + } + } + throw new Error(`Timeline item matching ${label} not found`); + }; + + it('highlights the selected step under the filter using the filtered index', () => { + renderMultiStep(); + + // Click the FunctionStep (original index 1) to select it. + const functionStepItem = findTimelineItem(/Action Executed/i); + fireEvent.click(functionStepItem); + + // Confirm it is rendered as selected before any filter is applied. + expect(document.querySelectorAll('.vscode-timeline-item--selected').length).toBe(1); + + // Apply a filter that hides earlier steps so the selected step's filtered + // position differs from its original plan index. + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'A123' } }); + + // The FunctionStep should still be the only selected timeline item, even though + // its filtered position (0) differs from its original plan index (1). + const selectedAfterFilter = document.querySelectorAll('.vscode-timeline-item--selected'); + expect(selectedAfterFilter.length).toBe(1); + expect(selectedAfterFilter[0].textContent).toMatch(/Action Executed/i); + }); + + it('does not highlight a step that is filtered out', () => { + renderMultiStep(); + + // Select the UserInputStep first + const userInputItem = findTimelineItem(/User Input/i); + fireEvent.click(userInputItem); + expect(document.querySelectorAll('.vscode-timeline-item--selected').length).toBe(1); + + // Filter out everything except the FunctionStep + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'A123' } }); + + // No timeline item should be marked selected + expect(document.querySelectorAll('.vscode-timeline-item--selected').length).toBe(0); + }); + }); + + it('clears the filter when a new traceHistory message arrives', () => { + render(); + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan-A', + sessionId: 'session-A', + plan: [{ type: 'UserInputStep', message: 'hello' }] + }; + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-A', + sessionId: 'session-A', + userMessage: 'first', + trace + } + ] + }); + + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'no_match_xyz' } }); + expect(input.value).toBe('no_match_xyz'); + + // New batch arrives + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-B', + sessionId: 'session-B', + userMessage: 'second', + trace: { ...trace, planId: 'plan-B', sessionId: 'session-B' } + } + ] + }); + + const inputAfter = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + expect(inputAfter.value).toBe(''); + expect(screen.getByText('second')).toBeInTheDocument(); + }); + + it('clears the filter when sessionStarted fires', () => { + render(); + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan-A', + sessionId: 'session-A', + plan: [{ type: 'UserInputStep', message: 'hello' }] + }; + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-A', + sessionId: 'session-A', + userMessage: 'first', + trace + } + ] + }); + + const input = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'something' } }); + expect(input.value).toBe('something'); + + dispatchMessage('sessionStarted', {}); + + // After sessionStarted, the placeholder is shown (no entries). Send a new history + // and verify the filter input renders empty. + dispatchMessage('traceHistory', { + entries: [ + { + storageKey: 'agent', + agentId: 'agent', + planId: 'plan-B', + sessionId: 'session-B', + userMessage: 'second', + trace: { ...trace, planId: 'plan-B', sessionId: 'session-B' } + } + ] + }); + + const inputAfter = screen.getByLabelText(/Filter trace history/i) as HTMLInputElement; + expect(inputAfter.value).toBe(''); }); }); }); diff --git a/webview/src/components/AgentTracer/AgentTracer.tsx b/webview/src/components/AgentTracer/AgentTracer.tsx index 3fa6bbf8..c8406ed6 100644 --- a/webview/src/components/AgentTracer/AgentTracer.tsx +++ b/webview/src/components/AgentTracer/AgentTracer.tsx @@ -100,6 +100,34 @@ export const selectHistoryEntry = (entries: TraceHistoryEntry[], index: number): return entries[index].trace ?? null; }; +export const translateStepIndexToFiltered = ( + plan: any[] | undefined, + originalStepIndex: number | null, + filterQuery: string +): number | undefined => { + if (originalStepIndex === null || originalStepIndex === undefined) { + return undefined; + } + if (!Array.isArray(plan)) { + return undefined; + } + const trimmed = (filterQuery ?? '').trim(); + if (!trimmed) { + return originalStepIndex; + } + + let filteredIndex = -1; + for (let i = 0; i < plan.length; i++) { + if (stepMatchesFilter(plan[i], trimmed)) { + filteredIndex++; + if (i === originalStepIndex) { + return filteredIndex; + } + } + } + return undefined; +}; + export const stepMatchesFilter = (step: any, query: string): boolean => { const trimmed = query.trim().toLowerCase(); if (!trimmed) { @@ -497,6 +525,7 @@ const AgentTracer: React.FC = ({ setSelectedStepIndex(null); setSelectedHistoryIndex(null); setExpandedPlanIds(new Set()); + setFilterQuery(''); }); // Request trace data when component mounts @@ -512,6 +541,7 @@ const AgentTracer: React.FC = ({ setSelectedStepIndex(null); setSelectedHistoryIndex(null); setExpandedPlanIds(new Set()); + setFilterQuery(''); setLoading(false); vscodeApi.postTestMessage('testTraceHistoryReceived', { entryCount: 0, entries: [] }); return; @@ -525,6 +555,7 @@ const AgentTracer: React.FC = ({ setTraceData(latestEntry.trace); setSelectedStepIndex(null); setSelectedHistoryIndex(null); + setFilterQuery(''); // Expand only the latest entry (collapse others) setExpandedPlanIds(new Set([latestEntry.planId])); @@ -662,6 +693,11 @@ const AgentTracer: React.FC = ({ stepFilter ); + const rowSelectedStepIndex = + selectedHistoryIndex === index + ? translateStepIndexToFiltered(entry.trace?.plan, selectedStepIndex, stepFilter) + : undefined; + return ( = ({ onOpenJson={() => handleRowOpenJson(entry)} timelineItems={timelineItems} message={message} - selectedStepIndex={selectedHistoryIndex === index ? selectedStepIndex ?? undefined : undefined} + selectedStepIndex={rowSelectedStepIndex} /> ); })}