diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx index 7821353adad..9e03319e5cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-layout.test.tsx @@ -110,7 +110,7 @@ describe('flat expanded activity layout', () => { expect(reservedSlot(column)?.classList).toContain(ICON_SLOT) }) - it('stacks main-lane blocks one gap-3 apart and search queries one gap-1.5 apart', () => { + it('keeps search and ordinary tool rows in the same history with shared spacing', () => { render('mothership', [ tool('a'), { @@ -138,9 +138,13 @@ describe('flat expanded activity layout', () => { expect(blocks.contains(statuses()[0])).toBe(true) expect(blocks.classList).toContain('gap-3') expect(blocks.classList).not.toContain('gap-1.5') - const queries = statuses().filter((status) => status.textContent === 'first') - const searchList = queries[0].closest('.flex-col.gap-1\\.5')! - expect(searchList).not.toBeNull() - expect(searchList.parentElement?.closest('.flex-col.gap-3')).toBe(blocks) + expect(statuses()).toHaveLength(1) + expand() + const rows = statuses().slice(1) + expect(rows).toHaveLength(3) + for (const row of rows) { + expect(row.closest('.flex-col')!.classList).toContain('gap-1.5') + expect(iconSlot(row).classList).toContain(ICON_SLOT) + } }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx index 977a74557aa..bfb3ab1811b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport.tsx @@ -6,7 +6,7 @@ import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/ interface ActivityViewportProps { children: ReactNode isStreaming: boolean - /** A nested blocking interaction must not be clipped by this ancestor's log viewport. */ + /** Keeps nested interactions or independently scrolling detail lists from being clipped. */ unbounded?: boolean } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts index 10928d3bc81..cef3e519855 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts @@ -14,7 +14,7 @@ function call(id: string, toolName: string, extra: Partial = {}): const layout = (items: AgentGroupItem[]) => splitMainLane(items).map((entry) => entry.type === 'run' - ? `${entry.run.isSearch ? 'search' : 'tools'}:${entry.run.tools.map((tool) => tool.id).join(',')}` + ? `tools:${entry.run.tools.map((tool) => tool.id).join(',')}` : entry.item.type ) @@ -24,7 +24,7 @@ const liveId = (items: AgentGroupItem[], isOpen = true) => { } describe('splitMainLane', () => { - it('splits runs at search boundaries and interactions, in transcript order', () => { + it('groups search with other tools and splits only at interactions, in transcript order', () => { expect( layout([ call('a', 'read'), @@ -35,7 +35,7 @@ describe('splitMainLane', () => { call('approval', 'edit_workflow', { status: 'awaiting_approval' }), call('c', 'read'), ]) - ).toEqual(['tools:a', 'search:s1,s2', 'tools:setup,b', 'tool', 'tools:c']) + ).toEqual(['tools:a,s1,s2,setup,b', 'tool', 'tools:c']) }) }) @@ -53,9 +53,9 @@ describe('getLaneLiveIndicator', () => { expect(indicator?.type === 'call' && indicator.tool.id).toBe('a') }) - it('gives the gap to a succeeded trailing call, never a finished search or a failure', () => { + it('gives the gap to a succeeded trailing call, including search, never a failure', () => { expect(liveId([call('s', 'search_workspace'), call('a', 'read')])).toBe('a') - expect(liveId([call('a', 'read'), call('s', 'search_workspace')])).toBeUndefined() + expect(liveId([call('a', 'read'), call('s', 'search_workspace')])).toBe('s') expect(liveId([call('a', 'read'), call('b', 'read', { status: 'error' })])).toBeUndefined() expect(liveId([call('a', 'read')], false)).toBeUndefined() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts index 156a64c42be..1d40da46ba6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts @@ -9,7 +9,6 @@ import type { AgentGroupItem, NestedAgentGroup, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' -import { isSearchActivityTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity' import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' @@ -27,10 +26,9 @@ function canHoldIndicator(tool: ToolCallData): boolean { return !needsToolInput(tool) && tool.toolName !== RETIRED_BROWSER_REQUEST_TAKEOVER_ID } -/** A run of consecutive calls the main lane renders together, as one group or one search list. */ +/** A run of consecutive calls the main lane renders together, as one activity group. */ export interface ActivityRun { tools: ToolCallData[] - isSearch: boolean } export type MainLaneEntry = @@ -38,8 +36,8 @@ export type MainLaneEntry = | { type: 'item'; item: AgentGroupItem; index: number } /** - * How the main lane lays out its items: consecutive calls form runs, a search - * call never shares a run with another kind of call, and an interaction stands + * How the main lane lays out its items: consecutive calls form runs, + * including search and document reads, and an interaction stands * on its own and closes the run before it. */ export function splitMainLane(items: AgentGroupItem[]): MainLaneEntry[] { @@ -47,9 +45,8 @@ export function splitMainLane(items: AgentGroupItem[]): MainLaneEntry[] { let run: ActivityRun | undefined for (const [index, item] of items.entries()) { if (item.type === 'tool' && !isStandaloneItem(item)) { - const isSearch = isSearchActivityTool(item.data) - if (!run || run.isSearch !== isSearch) { - run = { tools: [], isSearch } + if (!run) { + run = { tools: [] } entries.push({ type: 'run', run }) } run.tools.push(item.data) @@ -142,13 +139,10 @@ export interface LaneActivityInput { isOpen: boolean } -/** - * The latest call of the main lane's last run, which owns the trailing gap. A - * finished search shows static results, so its gap is never a call's. - */ +/** The latest call of the main lane's last run, which owns the trailing gap. */ function getMainTrailingCall(items: AgentGroupItem[]): ToolCallData | undefined { const last = splitMainLane(items).at(-1) - return last?.type === 'run' && !last.run.isSearch ? last.run.tools.at(-1) : undefined + return last?.type === 'run' ? last.run.tools.at(-1) : undefined } /** @@ -204,12 +198,11 @@ export interface TurnLiveIndicators { * call anywhere in the lane, across all of its runs and the lanes nested in * it. With none running and the lane open, the latest call of its trailing * run owns the gap, and is live only if it succeeded; an error, rejection, - * stop, skip, or interruption hands the wait to the thinking row. A finished - * main-lane search shows static results, so its gap is never live. A subagent + * stop, skip, or interruption hands the wait to the thinking row. A subagent * lane's trailing call is its latest call, and an open subagent lane with * narration but no calls shows its own "Thinking" header. - * - Only the run holding the live call shimmers: its tool group header, or its - * one search row. A parent lane whose live call sits in a nested lane defers + * - Only the run holding the live call shimmers through its tool group header. + * A parent lane whose live call sits in a nested lane defers * to that lane while the nested lane is still working and visible; a nested * lane that has ended hands its last call back to the parent. * - A header reads in the present tense exactly while it is live or its call diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx index 7bd0f2429ec..67aabae5498 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx @@ -2,7 +2,6 @@ import { type ComponentType, Fragment, type ReactNode } from 'react' import type { ToolActivity } from '@/lib/mothership/generated/protocol' import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' import { splitMainLane } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' -import { SearchActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity' import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' @@ -37,10 +36,8 @@ export function MainAgentActivity({ ) } - const { tools, isSearch } = entry.run - return isSearch ? ( - - ) : ( + const { tools } = entry.run + return ( 0 || noResults ? sources : undefined +} + +interface SearchActivityDetailsProps { + sources: SourceTagData[] + label: string +} + +/** Per-call evidence stays in the shared activity history, never in the live header. */ +export function SearchActivityDetails({ sources, label }: SearchActivityDetailsProps) { + return sources.length > 0 ? ( + + ) : ( +

No results

+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx index c993fa1f15c..a046cb97652 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx @@ -22,7 +22,7 @@ import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/component interface SearchActivityResultsProps { sources: SourceTagData[] - query: string + label: string } /** @@ -30,7 +30,7 @@ interface SearchActivityResultsProps { * transcript. The list shows four and a half 32px rows, so a clipped row signals * that it scrolls. */ -export function SearchActivityResults({ sources, query }: SearchActivityResultsProps) { +export function SearchActivityResults({ sources, label }: SearchActivityResultsProps) { const scrollRef = useRef(null) const edges = useScrollEdges(scrollRef) @@ -39,7 +39,7 @@ export function SearchActivityResults({ sources, query }: SearchActivityResultsP
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx index 8513cc19067..db9ce0c7a3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx @@ -1,29 +1,11 @@ /** @vitest-environment jsdom */ -import { act, type ReactNode } from 'react' +import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' -import { - isSearchActivityTool, - SearchActivity, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity' +import { ToolCallItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' -vi.mock( - '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport', - () => ({ - ActivityViewport: ({ children }: { children: React.ReactNode }) =>
{children}
, - }) -) -vi.mock( - '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group', - () => ({ - ToolActivityGroup: ({ tools }: { tools: ToolCallData[] }) => ( -
{tools.map((tool) => tool.displayTitle).join(', ')}
- ), - }) -) - let root: Root let container: HTMLDivElement beforeEach(() => { @@ -37,9 +19,24 @@ afterEach(() => { act(() => root.unmount()) container.remove() vi.unstubAllGlobals() + vi.useRealTimers() }) -const render = (element: ReactNode) => act(() => root.render(element)) +const render = (tools: ToolCallData[], liveToolId?: string) => + act(() => + root.render( + ({ type: 'tool', data }))} + ToolCallComponent={ToolCallItem} + renderItem={() => null} + autoScrollActivity={false} + liveToolId={liveToolId} + /> + ) + ) const header = () => container.querySelector('[role="button"]')! +const headerText = () => container.querySelector('[role="status"]')!.textContent +const expand = () => act(() => header().click()) + const tool: ToolCallData = { id: 'search-1', toolName: 'search_workspace', @@ -58,115 +55,6 @@ const tool: ToolCallData = { }, } -describe('inline search activity', () => { - it('keeps query history visible through completion without exposing raw arguments', () => { - render() - expect(container.textContent).toContain('Launch review') - expect(container.textContent).toContain('in:launch review') - expect(container.textContent).not.toContain('private-account') - expect(container.textContent).not.toContain('opaque-cursor') - render( - - ) - expect(header().getAttribute('aria-expanded')).toBe('false') - act(() => header().click()) - expect(header().getAttribute('aria-expanded')).toBe('true') - }) - - it('renders a completed query field from partial streamed arguments and retains stopped status', () => { - const streamed = { ...tool, params: undefined, streamingArgs: '{"query":"launch review"' } - render() - expect(container.textContent).toContain('launch review') - render() - expect(container.textContent).toBe('launch review · stopped') - expect(container.textContent).not.toContain('Searched sources') - }) - - it.each([ - ['preparing', { ...tool, params: undefined, streamingArgs: '{"que' }, 'Preparing query'], - ['running', tool, 'Launch review'], - [ - 'checking sources', - { ...tool, toolName: 'search_sources', params: { action: 'list' } }, - 'Checking connected sources', - ], - ['done', { ...tool, status: 'success' }, 'Launch review'], - ['failed', { ...tool, status: 'error' }, 'Launch review'], - [ - 'checked sources', - { ...tool, toolName: 'search_sources', status: 'success', params: { action: 'list' } }, - 'Checked connected sources', - ], - [ - 'failed sources check', - { ...tool, toolName: 'search_sources', status: 'error', params: { action: 'list' } }, - 'Checked connected sources', - ], - [ - 'stopped sources check', - { ...tool, toolName: 'search_sources', status: 'cancelled', params: { action: 'list' } }, - 'Connected sources', - ], - ] as const)( - 'labels a %s search and leaves it static unless its lane names it live', - (_state, call, label) => { - render() - const status = container.querySelector('[role="status"]') - expect(status?.textContent).toContain(label) - expect(status?.querySelector('[class*="shimmer"]')).toBeNull() - } - ) - - it('shimmers only the row holding the lane live call', () => { - const first = { ...tool, id: 'first', params: { query: 'First query' } } - const second = { ...tool, id: 'second', params: { query: 'Second query' } } - const liveLabels = () => - [...container.querySelectorAll('[role="status"]')] - .filter((row) => row.querySelector('[class*="shimmer"]')) - .map((row) => row.textContent) - render() - expect(liveLabels()).toEqual(['Second query']) - render() - expect(liveLabels()).toEqual(['First query']) - render() - expect(liveLabels()).toEqual([]) - }) - - it('keeps source setup and approval in the interactive tool renderer', () => { - expect( - isSearchActivityTool({ ...tool, toolName: 'search_sources', params: { action: 'list' } }) - ).toBe(true) - expect( - isSearchActivityTool({ ...tool, toolName: 'search_sources', params: { action: 'approve' } }) - ).toBe(false) - expect( - isSearchActivityTool({ ...tool, toolName: 'search_sources', params: { action: 'setup' } }) - ).toBe(false) - }) - - it('preserves chronological search, other tool, and answer sections', () => { - render( - null} - renderItem={(item) => (item.type === 'text' ? item.content : null)} - autoScrollActivity={false} - isActive={false} - /> - ) - expect(container.textContent).toMatch(/Launch review.*Read document.*Answer/) - }) -}) - function completedSearch(id: string, title: string): ToolCallData { return { ...tool, @@ -212,194 +100,210 @@ function completedSearch(id: string, title: string): ToolCallData { } } -it('keeps all ranked matches in each query and preserves disclosure state when later queries arrive', () => { - const first = completedSearch('one', 'First query') - render() - expect(header().getAttribute('aria-expanded')).toBe('false') - act(() => header().click()) - expect(header().getAttribute('aria-expanded')).toBe('true') - expect(container.querySelectorAll('a')).toHaveLength(3) - expect(container.textContent).toContain('First query first') - expect(container.textContent).not.toContain('First query duplicate') - expect(container.textContent).not.toContain('Unsafe result') - expect(container.textContent).toContain('example.com') - render() - expect(header().getAttribute('aria-expanded')).toBe('true') - const headers = container.querySelectorAll('[role="button"]') - expect(headers[1].getAttribute('aria-expanded')).toBe('false') - expect(container.querySelectorAll('a')).toHaveLength(3) - act(() => headers[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) - expect(container.querySelectorAll('a')).toHaveLength(6) - expect(container.querySelectorAll('[role="region"]')).toHaveLength(2) -}) - -it('falls back to a document icon when a source favicon is unavailable', () => { - render() - act(() => header().click()) - const firstLink = container.querySelector('a')! - expect(firstLink.querySelector('img')).not.toBeNull() - act(() => firstLink.querySelector('img')!.dispatchEvent(new Event('error'))) - expect(firstLink.querySelector('img')).toBeNull() - expect(firstLink.querySelector('svg')).not.toBeNull() -}) - -it('distinguishes empty search results from interrupted calls and never previews failed output', () => { - render() - expect(container.querySelectorAll('a')).toHaveLength(0) - expect(container.textContent).toBe('First query') - expect(container.querySelector('[role="button"]')).toBeNull() - render( - - ) - expect(container.textContent).toBe('Launch review · in:launch review · no results') -}) - -describe('described search rows', () => { - const described: ToolCallData = { ...tool, activityDescription: 'Searching launch review notes' } - const headerText = () => container.querySelector('[role="status"]')!.textContent - const outcomeSuffix = () => container.querySelector('[role="status"] + span') - - it.each([ - ['executing', 'Searching launch review notes', undefined], - ['success', 'Searched launch review notes', ' · 3 results'], - ['error', 'Searching launch review notes', undefined], - ['rejected', 'Searching launch review notes', ' · declined'], - ['cancelled', 'Stopped searching launch review notes', undefined], - ['interrupted', 'Stopped searching launch review notes', undefined], - ['skipped', 'Skipped searching launch review notes', undefined], - ] as const)('titles a %s call with the shared status wording', (status, title, suffix) => { - render( - +describe('search in shared tool activity', () => { + it('omits raw queries from both the transcript and accessibility labels', () => { + render([tool], tool.id) + expect(headerText()).toBe('Searching documents') + expect(header()).toBeNull() + render([ + { + ...completedSearch(tool.id, 'Launch review'), + params: { ...tool.params, query: 'raw-provider-query-sentinel' }, + }, + ]) + expect(header().getAttribute('aria-expanded')).toBe('false') + expand() + expect(container.querySelectorAll('a')).toHaveLength(3) + expect(container.querySelector('[role="region"]')?.getAttribute('aria-label')).toBe( + 'Search results for step 1: Searched documents' ) - expect(headerText()).toBe(title) - expect(outcomeSuffix()?.textContent).toBe(suffix) + expect(container.innerHTML).not.toContain('in:launch review') + expect(container.innerHTML).not.toContain('raw-provider-query-sentinel') + expect(container.textContent).not.toContain('private-account') + expect(container.textContent).not.toContain('opaque-cursor') }) - it('reads in progress only while running, and never on a finished call', () => { - render() - expect(headerText()).toBe('Searching launch review notes') - expect(container.querySelector('[class*="shimmer"]')).not.toBeNull() - render() - expect(headerText()).toBe('Searched launch review notes') + it('replaces a search with a document read in the same paced natural-language header', () => { + vi.useFakeTimers() + const search = { ...tool, activityDescription: 'Finding launch decisions' } + render([search], search.id) + const originalStatus = container.querySelector('[role="status"]') + expect(headerText()).toBe('Finding launch decisions') + const read: ToolCallData = { + id: 'read', + toolName: 'read_document', + displayTitle: 'Reading document', + activityDescription: 'Reading the launch plan', + status: 'executing', + } + render([{ ...search, status: 'success' }, read], read.id) + expect(container.querySelector('[role="status"]')).toBe(originalStatus) + expect(headerText()).toBe('Finding launch decisions') + act(() => vi.advanceTimersByTime(1000)) + expect(headerText()).toBe('Reading the launch plan') + expect(container.querySelectorAll('[role="status"]')).toHaveLength(1) + expect(container.querySelectorAll('[class*="shimmer"]')).toHaveLength(1) + render([ + { ...search, status: 'success' }, + { ...read, status: 'success' }, + ]) + expect(headerText()).toBe('Searched, read documents') expect(container.querySelector('[class*="shimmer"]')).toBeNull() }) - it('moves the raw query into one truncated line at the top of the body', () => { - render() - expect(header().getAttribute('aria-expanded')).toBe('true') - const body = container.querySelector(`#${CSS.escape(header().getAttribute('aria-controls')!)}`)! - const queryLine = body.querySelector('.text-caption')! - expect(queryLine.textContent).toBe('Launch review · in:launch review') - expect(queryLine.className).toContain('whitespace-nowrap') - expect(queryLine.className).toContain('overflow-hidden') - expect(headerText()).not.toContain('Launch review') - }) - - it('shows the query only once when there is no description', () => { - render() - act(() => header().click()) - expect(container.textContent?.match(/Launch review(?! first| second| third)/g)).toHaveLength(1) + it('retains the live search label between calls instead of returning to Thinking', () => { + const search = { + ...tool, + status: 'success' as const, + activityDescription: 'Finding launch decisions', + } + render([search], search.id) + expect(headerText()).toBe('Finding launch decisions') + render([search]) + expect(headerText()).toBe('Found launch decisions') }) - it('opens while running, closes on completion, and keeps a manual choice either way', () => { - render() + it('keeps every query snapshot, safe ranked links, and the manual disclosure choice as calls arrive', () => { + const first = completedSearch('one', 'First query') + render([first]) + expect(container.querySelectorAll('a')).toHaveLength(0) + expand() + expect(container.querySelectorAll('a')).toHaveLength(3) + expect(container.textContent).not.toContain('First query duplicate') + expect(container.textContent).not.toContain('Unsafe result') + expect(container.textContent).toContain('example.com') + render([first, completedSearch('two', 'Second query')]) expect(header().getAttribute('aria-expanded')).toBe('true') - render() + expect(container.querySelectorAll('a')).toHaveLength(6) + expect(container.querySelectorAll('[role="region"]')).toHaveLength(2) + expect( + [...container.querySelectorAll('[role="region"]')].map((region) => + region.getAttribute('aria-label') + ) + ).toEqual([ + 'Search results for step 1: Searched documents', + 'Search results for step 2: Searched documents', + ]) + act(() => header().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) expect(header().getAttribute('aria-expanded')).toBe('false') + }) - act(() => root.unmount()) - root = createRoot(container) - render() - act(() => header().click()) - expect(header().getAttribute('aria-expanded')).toBe('false') - render() - expect(header().getAttribute('aria-expanded')).toBe('false') - act(() => header().click()) + it('keeps a manually expanded query open through completion', () => { + const first = completedSearch('first', 'Initial matches') + render([first, tool], tool.id) + expand() + render([first, completedSearch(tool.id, 'Launch review')]) expect(header().getAttribute('aria-expanded')).toBe('true') + expect(container.querySelectorAll('a')).toHaveLength(6) + }) - act(() => root.unmount()) - root = createRoot(container) - render() - act(() => header().click()) - act(() => header().click()) - render() - expect(header().getAttribute('aria-expanded')).toBe('true') + it('never renders streamed query text', () => { + render([{ ...tool, params: undefined, streamingArgs: '{"query":"launch review"' }]) + expect(headerText()).toBe('Searching documents') + expect(header()).toBeNull() + expect(container.innerHTML).not.toContain('launch review') }) - it('renders a reloaded message with every finished search collapsed', () => { - render( - - ) - const headers = [...container.querySelectorAll('[role="button"]')] - expect(headers.map((row) => row.getAttribute('aria-expanded'))).toEqual([ - 'false', - 'false', - 'false', + it('restores a completed search/read sequence as one collapsed activity', () => { + render([ + completedSearch('one', 'First query'), + { id: 'read', toolName: 'read_document', displayTitle: 'Read document', status: 'success' }, + completedSearch('two', 'Second query'), ]) + expect(container.querySelectorAll('[role="button"]')).toHaveLength(1) expect(container.querySelectorAll('a')).toHaveLength(0) + expect(container.textContent).not.toMatch(/First query|Second query|results/) }) - it('keeps a failed search silent: static neutral title, no failure text or error styling', () => { - render() - expect(container.querySelector('[role="button"]')).toBeNull() - expect(container.textContent).toBe('Launch review · in:launch review') - render( - - ) - expect(header().getAttribute('aria-expanded')).toBe('false') - expect(headerText()).toBe('Searching launch review notes') - expect(outcomeSuffix()).toBeNull() - expect(container.querySelector('[class*="shimmer"]')).toBeNull() - act(() => header().click()) + it('never previews failed output or adds search-only failure chrome', () => { + render([{ ...completedSearch('one', 'First query'), status: 'error' }]) + expect(headerText()).toBe('Searching documents') + expect(header()).toBeNull() expect(container.querySelectorAll('a')).toHaveLength(0) - expect(container.textContent).toBe( - 'Searching launch review notesLaunch review · in:launch review' - ) - expect(container.textContent).not.toMatch(/fail/i) + expect(container.textContent).not.toMatch(/failed|no results/i) expect(container.innerHTML).not.toContain('--text-error') }) - it('keeps user-initiated and empty outcomes as muted suffixes', () => { - render() - expect(outcomeSuffix()?.textContent).toBe(' · declined') - expect(outcomeSuffix()?.className).toContain('text-[var(--text-tertiary)]') - render() - expect(outcomeSuffix()?.textContent).toBe(' · skipped') - render() - expect(outcomeSuffix()?.textContent).toBe(' · 3 results') - expect(container.innerHTML).not.toContain('--text-error') + it.each([ + { success: false, data: { results: [] } }, + {}, + { + data: { + results: [ + { citationId: 'unsafe', citationUrl: 'javascript:alert(1)', documentName: 'Unsafe' }, + ], + }, + }, + ])('does not expose an empty disclosure for unusable saved output', (output) => { + render([{ ...tool, status: 'success', result: { success: true, output } }]) + expect(header()).toBeNull() + expect(container.querySelectorAll('a')).toHaveLength(0) }) - it('lays results out in dense rows with a matching bounded list', () => { - render() - act(() => header().click()) - expect(container.querySelector('[role="region"]')!.className).toContain('max-h-[152px]') - for (const link of container.querySelectorAll('a')) { - expect(link.className).toContain('h-8') - expect(link.className).not.toContain('h-10') + it.each([undefined, { status: 'complete', timedOutLegs: [] }])( + 'shows a complete or legacy empty result only in its history, without a header count', + (retrieval) => { + render([ + { + ...tool, + status: 'success', + result: { success: true, output: { data: { results: [], retrieval } } }, + }, + ]) + expect(headerText()).toBe('Searched documents') + expect(container.textContent).not.toContain('results') + expand() + expect(container.textContent).toContain('No results') } + ) + + it('does not claim no results or expose an empty disclosure when retrieval is partial', () => { + render([ + { + ...tool, + status: 'success', + result: { + success: true, + output: { + data: { results: [], retrieval: { status: 'partial', timedOutLegs: ['vector'] } }, + }, + }, + }, + ]) + expect(headerText()).toBe('Searched documents') + expect(header()).toBeNull() + expect(container.textContent).not.toContain('No results') + }) + + it('preserves available source matches when retrieval is partial', () => { + const search = completedSearch('partial', 'Available matches') + const output = search.result!.output as { data: Record } + output.data.retrieval = { status: 'partial', timedOutLegs: ['vector'] } + render([search]) + expand() + expect(container.querySelectorAll('a')).toHaveLength(3) + expect(container.textContent).not.toContain('No results') + }) + + it.each([ + ['executing', 'Searching launch review notes'], + ['success', 'Searched launch review notes'], + ['error', 'Searching launch review notes'], + ['rejected', 'Searching launch review notes'], + ['cancelled', 'Stopped searching launch review notes'], + ['interrupted', 'Stopped searching launch review notes'], + ['skipped', 'Skipped searching launch review notes'], + ] as const)('uses ordinary tool wording for %s', (status, expected) => { + render([{ ...tool, status, activityDescription: 'Searching launch review notes' }]) + expect(headerText()).toBe(expected) + }) + + it('falls back to a document icon if a source favicon fails', () => { + render([completedSearch('one', 'First query')]) + expand() + const link = container.querySelector('a')! + act(() => link.querySelector('img')!.dispatchEvent(new Event('error'))) + expect(link.querySelector('img')).toBeNull() + expect(link.querySelector('svg')).not.toBeNull() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx deleted file mode 100644 index d3878ed50c4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx +++ /dev/null @@ -1,192 +0,0 @@ -'use client' - -import { useState } from 'react' -import { cn, OverflowText } from '@sim/emcn' -import { Search } from '@sim/emcn/icons' -import { toStringOrNull } from '@sim/utils/coerce' -import { toArray, toRecord } from '@sim/utils/object' -import { ACTIVITY_LABEL_CLASS, ActivityStatus } from '@/components/ui/activity-status' -import { - collectRetrievalCitationEvidence, - parseCitationRecord, -} from '@/lib/mothership/chat/citation-evidence' -import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' -import { - getToolInProgressTitle, - getToolStatusDisplayTitle, - normalizeToolActivityDescription, -} from '@/lib/mothership/tools/tool-display' -import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure' -import { SearchActivityResults } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results' -import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url' -import { isToolDone } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' -import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' - -/** Source setup and approval keep their interactive tool presentation. */ -export function isSearchActivityTool(tool: ToolCallData): boolean { - return ( - tool.toolName === 'search_workspace' || - (tool.toolName === 'search_sources' && - ['list', 'get', 'providers'].includes(toStringOrNull(tool.params?.action) ?? '')) - ) -} - -/** Show only query text, never account identifiers, cursors, or raw tool output. */ -function searchQueries(tool: ToolCallData): string[] { - const query = - toStringOrNull(tool.params?.query) ?? - extractStreamingStringArgument(tool.streamingArgs, 'query') - const nativeQueries = toArray(tool.params?.nativeQueries).flatMap((entry) => { - const value = toStringOrNull(toRecord(entry).query)?.trim() - return value ? [value] : [] - }) - return [...new Set([...(query?.trim() ? [query.trim()] : []), ...nativeQueries])] -} - -/** - * What a finished search came to, as a header suffix in the " · N stopped" - * style of tool group headers, so the outcome reads while the row is - * collapsed. A described stop or skip already leads its title with the - * outcome, so it gets no suffix. A failure stays silent and keeps the neutral - * title tool rows give errored calls. - */ -function searchOutcome( - tool: ToolCallData, - described: boolean, - resultCount: number, - noResults: boolean -): string | undefined { - switch (tool.status) { - case ToolCallStatus.rejected: - return 'declined' - case ToolCallStatus.cancelled: - case ToolCallStatus.interrupted: - return described ? undefined : 'stopped' - case ToolCallStatus.skipped: - return described ? undefined : 'skipped' - case ToolCallStatus.success: - if (noResults) return 'no results' - return resultCount > 0 - ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` - : undefined - default: - return undefined - } -} - -/** - * The header title: the model's description of the call, in the tense and - * outcome wording tool rows use, else the query text, else what the call is - * doing. Tense follows liveness: a finished call reads as succeeded, stopped, - * skipped, or in the neutral wording, never as still running. A sources check - * that never ran reads as a bare noun, leaving its suffix to say why. - */ -function searchTitle( - tool: ToolCallData, - description: string | undefined, - queryText: string, - working: boolean -): string { - if (description) { - const title = working ? getToolInProgressTitle : getToolStatusDisplayTitle - return title(tool.displayTitle, tool.status, tool.toolName, description) - } - if (queryText) return queryText - if (tool.toolName === 'search_sources') { - if (!isToolDone(tool.status)) return 'Checking connected sources' - return tool.status === ToolCallStatus.success || tool.status === ToolCallStatus.error - ? 'Checked connected sources' - : 'Connected sources' - } - return 'Preparing query' -} - -interface SearchQueryActivityProps { - tool: ToolCallData - /** This row holds its lane's one live indicator. */ - isLive: boolean -} - -/** - * Each search call owns a stable result snapshot, so later searches never - * replace it. A row is open while its call runs and closes once it finishes; - * a toggle by the user sticks for that row. - */ -function SearchQueryActivity({ tool, isLive }: SearchQueryActivityProps) { - const [manualExpanded, setManualExpanded] = useState(null) - const done = isToolDone(tool.status) - const expanded = manualExpanded ?? !done - const description = normalizeToolActivityDescription(tool.activityDescription) - const queryText = searchQueries(tool).join(' · ') - const evidence = collectRetrievalCitationEvidence([ - { toolCall: { name: tool.toolName, status: tool.status, result: tool.result } }, - ]) - const sources = [...indexSourcesByUrl(evidence.values()).values()] - const output = parseCitationRecord(tool.result?.output) - const data = parseCitationRecord(output?.data) ?? output - const noResults = Boolean( - tool.toolName === 'search_workspace' && - tool.status === ToolCallStatus.success && - tool.result?.success && - output?.success !== false && - Array.isArray(data?.results) && - data.results.length === 0 - ) - - const title = searchTitle(tool, description, queryText, isLive || !done) - const outcome = searchOutcome(tool, Boolean(description), sources.length, noResults) - const showQuery = Boolean(description && queryText) - - return ( - - } - /> - {outcome && ( - - {` · ${outcome}`} - - )} - - } - expanded={expanded} - onToggle={() => setManualExpanded(!expanded)} - isStreaming={false} - collapsible={showQuery || sources.length > 0} - unbounded - > -
- {showQuery && ( - - )} - {sources.length > 0 && ( - - )} -
-
- ) -} - -interface SearchActivityProps { - tools: ToolCallData[] - /** The call holding the lane's live indicator, which only a running search can be. */ - liveToolId?: string -} - -/** - * Search history and its results stay inspectable without changing the - * selected panel. Only the row holding the lane's live call shimmers. - */ -export function SearchActivity({ tools, liveToolId }: SearchActivityProps) { - return ( -
- {tools.map((tool) => ( - - ))} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts index 872bedbc10f..c2a39e712c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts @@ -150,6 +150,10 @@ describe('in-progress activity header', () => { ['a CLI call before its command parses', pendingCli], ['a code call before its arguments resolve', tool('run_code', 'Running code', 'executing')], ['a gateway call before its description streams', pendingGateway], + [ + 'a search before its arguments arrive', + tool('search_workspace', 'Searching documents', 'executing'), + ], ])('holds the previous call for %s', (_case, pending) => { const tools = [read, pending] expect(getActivityHeaderTool(tools, pending)).toBe(read) @@ -164,6 +168,10 @@ describe('in-progress activity header', () => { expect(getActivityHeaderTool(tools, pendingCli)).toBe(read) }) + it('never borrows a later parallel call as the previous header', () => { + expect(getActivityHeaderTool([pendingCli, read], pendingCli)).toBe(pendingCli) + }) + it('never holds a failed call, falling back to the new call itself', () => { const failed = tool('grep', 'Searching', 'error') expect(getActivityHeaderTool([failed, pendingCli], pendingCli)).toBe(pendingCli) @@ -189,6 +197,10 @@ describe('in-progress activity header', () => { }) it.each([ + [ + 'a parsed search query', + tool('search_workspace', 'Searching documents', 'executing', { query: 'launch' }), + ], ['a parsed CLI command', tool('cli_workflows_list', 'Listing workflows', 'executing')], [ 'resolved code arguments', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx index f5036e77f18..9b2ad1686cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx @@ -12,6 +12,10 @@ import { import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' import { getNewestRunningTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' +import { + getSearchActivitySources, + SearchActivityDetails, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details' import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' import { getActivityAttentionKey, @@ -72,8 +76,9 @@ function getToolActivityInterruptions(tools: ToolCallData[]): string[] { /** * An executing call whose streamed arguments do not name its action yet, so its - * title is only its tool's placeholder: a `sim_cli` call before its command - * parses ("Running CLI command") or a `run_code` call before its arguments + * title is only its tool's placeholder: a search before its arguments arrive, + * a `sim_cli` call before its command parses ("Running CLI command"), + * a `run_code` call before its arguments * resolve, while their parameters hold at most the activity, and an * integration gateway call before its description streams ("Calling integration"). * A call awaiting approval is never untitled, so its permission card keeps the @@ -87,7 +92,9 @@ function isAwaitingTitle(tool: ToolCallData): boolean { return !(typeof description === 'string' && description.trim()) } return ( - (tool.toolName === 'sim_cli' || tool.toolName === RunCode.id) && + (tool.toolName === 'sim_cli' || + tool.toolName === RunCode.id || + tool.toolName === 'search_workspace') && Object.keys(tool.params ?? {}).every((key) => key === 'activity') ) } @@ -109,13 +116,12 @@ export function getActivityHeaderTool( if (!isAwaitingTitle(statusTool)) return statusTool return ( getActivityStatusTool( - tools.filter( - (tool) => - tool.id !== statusTool.id && - !isAwaitingTitle(tool) && - !needsToolInput(tool) && - !isFailedTool(tool) - ) + tools + .slice( + 0, + tools.findIndex((tool) => tool.id === statusTool.id) + ) + .filter((tool) => !isAwaitingTitle(tool) && !needsToolInput(tool) && !isFailedTool(tool)) ) ?? statusTool ) } @@ -204,6 +210,8 @@ export function ToolActivityGroup({ /** Tense follows liveness: a live group, or one with a call still running, reads in progress. */ const working = isLive || tools.some((tool) => !isToolDone(tool.status)) const attentionKey = getActivityAttentionKey(tools) + const entries = tools.map((tool) => ({ tool, sources: getSearchActivitySources(tool) })) + const hasSearchDetails = entries.some(({ sources }) => sources !== undefined) return ( 1 ? groupedActivity?.title : undefined} - collapsible={tools.length > 1} + collapsible={tools.length > 1 || hasSearchDetails} expanded={expanded} onToggle={() => setExpanded(!expanded)} isStreaming={working && autoScrollActivity} + unbounded={entries.some(({ sources }) => (sources?.length ?? 0) > 0)} >
- {tools.map((tool) => ( + {entries.map(({ tool, sources }, index) => ( - {tool.id === headerTool.id ? ( + {tools.length === 1 ? null : tool.id === headerTool.id ? ( ) : ( )} + {sources && ( + + )} ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 71c63bd116b..0f7a92f3378 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -76,7 +76,7 @@ function nestedStringParam(params: Record | undefined, key: str * An executing `browser_request_takeover` is lifted by AgentGroup into its * parent flow; this row remains the canonical completed-history entry after * the browser agent resumes. - * Rows are history and never shimmer; the lane decides which header or search row is live. + * Rows are history and never shimmer; the lane decides which header is live. */ export function ToolCallItem({ toolName, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx index c7af52fffb1..5f1548798f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content-thinking.test.tsx @@ -176,7 +176,7 @@ describe('MessageContent shared thinking indicator', () => { it.each([ ['executing', true], - ['success', false], + ['success', true], ] as const)( 'shows exactly one live indicator around a %s main search', (status, searchIsLive) => { @@ -483,12 +483,12 @@ describe('MessageContent shared thinking indicator', () => { [ 'a running search before a finished call', [search('s', 'executing'), call('r', 'search_docs', 'success')], - 'Query s', + 'Title s', ], [ 'a still-streaming search before a finished call', [call('s', 'search_workspace', 'executing'), call('r', 'search_docs', 'success')], - 'Preparing query', + 'Title s', ], [ 'an older search and a newer call both running', @@ -498,7 +498,7 @@ describe('MessageContent shared thinking indicator', () => { [ 'an older call and a newer search both running', [call('r', 'search_docs', 'executing', { startedAtMs: 1 }), search('s', 'executing', 2)], - 'Query s', + 'Title s', ], ] as const)('shows exactly one indicator for %s', (_case, blocks, live) => { settle([...blocks]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index b2ef813729e..20da3a1cf51 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -1091,9 +1091,9 @@ describe('turn wait ownership', () => { expect(ownsWait(parseBlocks(blocks, true), true)).toBe(true) }) - it('leaves the gap after a finished trailing search, which shows static results, to thinking', () => { + it('keeps the search activity live between calls in an open lane', () => { const blocks = [mainToolCall('read', 'read'), searchCall('search', 'success')] - expect(ownsWait(parseBlocks(blocks, true), true)).toBe(false) + expect(ownsWait(parseBlocks(blocks, true), true)).toBe(true) }) it('lets the open group own the gap once a later non-search call follows a search', () => { diff --git a/apps/sim/lib/mothership/tools/tool-activity.ts b/apps/sim/lib/mothership/tools/tool-activity.ts index 2a1090b0a7e..6bd57b10ba1 100644 --- a/apps/sim/lib/mothership/tools/tool-activity.ts +++ b/apps/sim/lib/mothership/tools/tool-activity.ts @@ -345,7 +345,7 @@ export const TOOL_ACTIVITIES: Readonly