Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ function items(tools: ToolCallData[]): AgentGroupItem[] {
return tools.map((data) => ({ type: 'tool', data }))
}

describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (agentName) => {
describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (agentName) => {
let root: Root
let container: HTMLDivElement
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false }))
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
Expand All @@ -30,6 +31,7 @@ describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (age
act(() => root.unmount())
container.remove()
vi.useRealTimers()
vi.unstubAllGlobals()
})
const render = (tools: ToolCallData[], active = true) =>
act(() =>
Expand All @@ -46,6 +48,34 @@ describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (age
const header = () => container.querySelector('[role="status"]')
const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms))

it('leaves empty lanes to the turn indicator and shows the first action immediately', () => {
render([])
expect(container.childElementCount).toBe(0)
advance(100)
render([tool('first')])
expect(header()?.textContent).toBe('Reading first')
const row = header()
render([tool('first', 'success')], false)
expect(header()?.textContent).toBe('Read first')
expect(header()).toBe(row)
})

it('preserves a successful model description in the header and expanded history', () => {
const completed = {
...tool('first', 'success'),
activityDescription: 'Read the latest inbox emails',
}
render([completed], false)
expect(header()?.textContent).toBe(completed.activityDescription)
render([completed, tool('second', 'success')], false)
const trigger = container.querySelector<HTMLElement>('[role="button"]')!
act(() => trigger.click())
expect(container.querySelector('[data-state="open"]')?.textContent).toContain(
completed.activityDescription
)
expect(container.textContent).not.toContain('Completed:')
})

it('shows the first action immediately and coalesces bursts without replaying a backlog', () => {
render([])
advance(100)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type {
AgentGroupItem,
NestedAgentGroup,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'

/** Empty agent lanes share the turn's thinking indicator until they have output. */
export function hasAgentGroupItemContent(item: AgentGroupItem): boolean {
switch (item.type) {
case 'tool':
return true
case 'text':
return item.content.trim().length > 0
case 'agent_group':
return item.group.items.some(hasAgentGroupItemContent)
}
}

/** Finds empty live lanes at any depth whose wait belongs to the turn indicator. */
export function hasPendingAgentGroup(
group: Pick<NestedAgentGroup, 'items' | 'isOpen' | 'isDelegating'>
): boolean {
return (
Comment thread
waleedlatif1 marked this conversation as resolved.
((group.isOpen || group.isDelegating) && !group.items.some(hasAgentGroupItemContent)) ||
group.items.some((item) => item.type === 'agent_group' && hasPendingAgentGroup(item.group))
)
}

/**
* Every tool in a group, in stream order, including those run by nested
* agents. A parent's status line speaks for the whole subtree it delegated,
* so a grandchild's work is what surfaces while the parent itself waits.
*/
export function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] {
const tools: ToolCallData[] = []
const walk = (list: AgentGroupItem[]) => {
for (const item of list) {
if (item.type === 'tool') tools.push(item.data)
else if (item.type === 'agent_group') walk(item.group.items)
}
}
walk(items)
return tools
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
'use client'

import { type ComponentType, type ReactNode, useState } from 'react'
import { ThinkingLoader } from '@/components/ui/thinking-loader'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
import {
collectGroupTools,
hasAgentGroupItemContent,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content'
import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon'
import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown'
import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
Expand Down Expand Up @@ -67,23 +72,6 @@ function activeToolTitle(tool: ToolCallData): string {
)
}

/**
* Every tool in a group, in stream order, including those run by nested
* agents. A parent's status line speaks for the whole subtree it delegated,
* so a grandchild's work is what surfaces while the parent itself waits.
*/
function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] {
const tools: ToolCallData[] = []
const walk = (list: AgentGroupItem[]) => {
for (const item of list) {
if (item.type === 'tool') tools.push(item.data)
else if (item.type === 'agent_group') walk(item.group.items)
}
}
walk(items)
return tools
}

/** Reveal blocking interactions even when a parent group was manually collapsed. */
function hasPendingInteraction(items: AgentGroupItem[]): boolean {
return items.some((item) => {
Expand Down Expand Up @@ -162,12 +150,6 @@ export function AgentGroupView({
renderBrowserTakeover,
}: AgentGroupViewProps) {
const AgentIcon = getAgentIcon(agentName)
const agentIcon =
agentName === 'browser' ? (
<BrowserAgentIcon items={items} />
) : (
<AgentIcon className='size-full' />
)
const isMainAgent = agentName === 'mothership'
const tools = isMainAgent ? [] : collectGroupTools(items)
const statusTool = getActivityStatusTool(tools)
Expand All @@ -178,6 +160,14 @@ export function AgentGroupView({
const nestedBrowserTakeover = browserAgentAvailable && hasNestedBrowserTakeover(items)
const isWorking =
!activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen))
const agentIcon =
isWorking && !statusTool ? (
<ThinkingLoader size={14} startVariant='corners' />
) : agentName === 'browser' ? (
<BrowserAgentIcon items={items} />
) : (
<AgentIcon className='size-full' />
)

const [manualExpanded, setManualExpanded] = useState(defaultExpanded)
const [expandedTakeoverId, setExpandedTakeoverId] = useState<string | null>(null)
Expand All @@ -188,6 +178,9 @@ export function AgentGroupView({
nestedBrowserTakeover ||
(activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded)

const meaningfulItems = items.filter(hasAgentGroupItemContent)
if (meaningfulItems.length === 0) return null

const toggleExpanded = () => {
if (activeBrowserTakeover) {
setExpandedTakeoverId(expanded ? null : activeBrowserTakeover.id)
Expand Down Expand Up @@ -229,6 +222,7 @@ export function AgentGroupView({
/>
)
}
if (!item.content.trim()) return null
return (
<NarrationText
key={`text-${idx}`}
Expand Down Expand Up @@ -262,8 +256,8 @@ export function AgentGroupView({
statusTool.status === ToolCallStatus.executing ||
statusTool.status === ToolCallStatus.success)
const collapsible =
items.length > 1 ||
items.some(
meaningfulItems.length > 1 ||
meaningfulItems.some(
(item) =>
item.type !== 'tool' ||
needsToolInput(item.data) ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MessageContent } from '@/app/workspace/[workspaceId]/home/components/message-content/message-content'
import type { ContentBlock, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'

vi.mock('@/lib/auth/auth-client', () => ({
useSession: vi.fn(() => ({ data: null, isPending: false })),
}))

function start(name: string, parentSpanId = 'main'): ContentBlock {
return { type: 'subagent', content: name, spanId: name, parentSpanId, timestamp: 1 }
}

function tool(spanId: string, status: ToolCallStatus = 'executing'): ContentBlock {
return {
type: 'tool_call',
spanId,
toolCall: {
id: `${spanId}-read`,
name: 'read',
calledBy: spanId,
status,
activityDescription: `Reading ${spanId} notes`,
},
timestamp: 2,
}
}

describe('MessageContent shared thinking indicator', () => {
let queryClient: QueryClient
let root: Root
let container: HTMLDivElement

beforeEach(() => {
queryClient = new QueryClient()
vi.useFakeTimers()
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false }))
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
queryClient.clear()
vi.useRealTimers()
vi.unstubAllGlobals()
})

const render = (blocks: ContentBlock[], isStreaming = true) =>
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<MessageContent blocks={blocks} fallbackContent='' isStreaming={isStreaming} isLast />
</QueryClientProvider>
)
})
const thinking = () => container.querySelectorAll('[aria-hidden="false"] svg')
const groups = () => container.querySelectorAll('[data-agent-group]')

it('shares one indicator across parallel and nested empty agents', () => {
render([start('workflow'), start('browser'), start('deploy', 'workflow')])
expect(thinking()).toHaveLength(1)
expect(groups()).toHaveLength(0)
expect(container.querySelector('[role="status"]')).toBeNull()
})

it('hands off immediately to meaningful activity without remounting existing rows', () => {
const blocks = [start('workflow'), start('browser')]
render(blocks)
expect(thinking()).toHaveLength(1)
render([...blocks, tool('workflow')])
expect(thinking()).toHaveLength(0)
expect(groups()).toHaveLength(1)
const firstRow = container.querySelector('[role="status"]')
expect(firstRow?.textContent).toBe('Reading workflow notes')
render([...blocks, tool('workflow'), tool('browser')])
expect(groups()).toHaveLength(2)
expect(container.querySelector('[role="status"]')).toBe(firstRow)
expect(thinking()).toHaveLength(0)
})

it('shows the pending indicator after the only visible agent finishes', () => {
const blocks = [start('workflow'), start('browser'), tool('workflow', 'success')]
render(blocks)
expect(thinking()).toHaveLength(0)
render([...blocks, { type: 'subagent_end', spanId: 'workflow', timestamp: 3 }])
expect(thinking()).toHaveLength(1)
expect(groups()).toHaveLength(1)
})

it('keeps a singleton action flat when its nested agent has no output', () => {
render([start('workflow'), tool('workflow'), start('deploy', 'workflow')])
expect(groups()).toHaveLength(1)
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1)
expect(container.querySelector('[role="button"]')).toBeNull()
expect(thinking()).toHaveLength(0)
})

it('retains nested activity while another child is empty', () => {
render([
start('workflow'),
start('deploy', 'workflow'),
start('browser', 'workflow'),
tool('deploy'),
])
const trigger = container.querySelector<HTMLElement>('[role="button"]')!
act(() => trigger.click())
expect(container.querySelector('[data-state="open"]')?.textContent).toContain(
'Reading deploy notes'
)
expect(container.querySelectorAll('[role="status"]')).toHaveLength(2)
expect(thinking()).toHaveLength(0)
})

it('preserves narration and skips whitespace-only output', () => {
const blocks: ContentBlock[] = [
start('workflow'),
{ type: 'subagent_text', spanId: 'workflow', content: ' ', timestamp: 2 },
]
render(blocks)
expect(groups()).toHaveLength(0)
expect(thinking()).toHaveLength(1)
render(
[
...blocks,
{ type: 'subagent_text', spanId: 'workflow', content: 'Checking the setup.', timestamp: 3 },
],
false
)
expect(groups()).toHaveLength(1)
act(() => container.querySelector<HTMLElement>('[role="button"]')!.click())
expect(container.querySelector('[data-state="open"]')?.textContent).toContain(
'Checking the setup.'
)
})

it.each(['awaiting_approval', 'error', 'cancelled', 'rejected'] as const)(
'keeps %s tool rows visible while another agent is pending',
(status) => {
render([start('workflow'), start('browser'), tool('workflow', status)])
expect(groups()).toHaveLength(1)
expect(container.querySelector('[role="status"]')?.textContent).toContain('workflow notes')
expect(thinking()).toHaveLength(1)
}
)

it('keeps thinking hidden while prose streams and finishes revealing', () => {
const blocks: ContentBlock[] = [
start('browser'),
{
type: 'text',
content: 'Here is the result of reviewing the project and checking its configuration.',
timestamp: 3,
},
]
render([start('browser'), { type: 'text', content: 'Here is', timestamp: 3 }])
render(blocks)
act(() => vi.advanceTimersByTime(100))
expect(thinking()).toHaveLength(0)
render(blocks, false)
expect(thinking()).toHaveLength(0)
})

it('waits for the normal quiet period between prose chunks while an agent is pending', () => {
const prose = 'Here is the result of reviewing the project and checking its configuration.'
const blocks: ContentBlock[] = [
start('browser'),
{ type: 'text', content: prose, timestamp: 3 },
]
render(blocks)
expect(thinking()).toHaveLength(0)
act(() => vi.advanceTimersByTime(1_499))
expect(thinking()).toHaveLength(0)
act(() => vi.advanceTimersByTime(1))
expect(thinking()).toHaveLength(1)
render([...blocks, { type: 'text', content: ' The configuration is valid.', timestamp: 4 }])
expect(thinking()).toHaveLength(0)
})

it('removes thinking when a turn stops or finishes without agent output', () => {
const blocks = [start('workflow'), start('browser')]
render(blocks)
expect(thinking()).toHaveLength(1)
render([...blocks, { type: 'stopped', timestamp: 3 }], false)
expect(thinking()).toHaveLength(0)
expect(groups()).toHaveLength(0)
expect(container.textContent).toContain('Stopped')
render(blocks, false)
expect(thinking()).toHaveLength(0)
expect(groups()).toHaveLength(0)
})
})
Loading
Loading