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 @@ -2,7 +2,10 @@ import { Table } from '@sim/emcn/icons'
import { SlackIcon } from '@/components/icons'
import { ActivityStatus } from '@/components/ui/activity-status'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import type {
ToolActivityPresentation,
ToolCallItemProps,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'

/** Demo fixtures have known brands, so the landing page never loads the block registry. */
Expand All @@ -20,12 +23,16 @@ export function HeroToolCallItem({
: toolCallId === 'hero-read-table'
? Table
: getToolIcon(toolName)
const activity = (
<ActivityStatus
label={getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription)}
isActive={status === 'executing'}
icon={<Icon className='size-full' />}
/>
)
return renderStatus ? renderStatus(activity) : activity
const activity: ToolActivityPresentation = {
label: getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription),
activeLabel: getToolStatusDisplayTitle(
displayTitle,
status === 'success' ? 'executing' : status,
toolName,
activityDescription
),
isActive: status === 'executing',
icon: <Icon className='size-full' />,
}
return renderStatus ? renderStatus(activity) : <ActivityStatus {...activity} />
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
'use client'

import { type ReactNode, useId } from 'react'
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { ChevronDown, cn, Expandable, ExpandableContent, handleKeyboardActivation } from '@sim/emcn'
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'

interface ActivityDisclosureProps {
export interface ActivityDisclosureProps {
header: ReactNode
children: ReactNode
expanded: boolean
onToggle: () => void
isStreaming: boolean
collapsible?: boolean
unbounded?: boolean
}

Expand All @@ -21,39 +22,51 @@ export function ActivityDisclosure({
onToggle,
isStreaming,
unbounded = false,
collapsible = true,
}: ActivityDisclosureProps) {
const contentId = useId()
const headerId = useId()

return (
<div className='flex min-w-0 flex-col gap-1.5'>
<button
type='button'
aria-expanded={expanded}
aria-controls={contentId}
aria-labelledby={headerId}
onClick={onToggle}
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
<div className='flex min-w-0 flex-col'>
<div
role={collapsible ? 'button' : undefined}
tabIndex={collapsible ? 0 : undefined}
aria-expanded={collapsible ? expanded : undefined}
aria-controls={collapsible ? contentId : undefined}
aria-labelledby={collapsible ? headerId : undefined}
onClick={collapsible ? onToggle : undefined}
onKeyDown={collapsible ? (event) => handleKeyboardActivation(event, onToggle) : undefined}
className={cn(
'flex w-full min-w-0 items-center gap-2 text-left',
collapsible && 'group/agent cursor-pointer'
)}
>
<span id={headerId} className='flex min-w-0'>
{header}
</span>
<ChevronDown
aria-hidden
className={cn(
'size-[14px] shrink-0 text-[var(--text-icon)] transition-[transform,opacity] duration-150',
!expanded &&
'-rotate-90 opacity-0 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100'
)}
/>
</button>
<Expandable expanded={expanded}>
<ExpandableContent id={contentId}>
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
{children}
</ActivityViewport>
</ExpandableContent>
</Expandable>
{collapsible && (
<ChevronDown
aria-hidden
className={cn(
'size-[14px] shrink-0 text-[var(--text-icon)] transition-[transform,opacity] duration-150',
!expanded &&
'-rotate-90 opacity-0 group-hover/agent:opacity-100 group-focus-visible/agent:opacity-100'
)}
/>
)}
</div>
{collapsible && (
<Expandable expanded={expanded}>
<ExpandableContent id={contentId}>
<div className='pt-1.5'>
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
{children}
</ActivityViewport>
</div>
</ExpandableContent>
</Expandable>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'

function tool(id: string, status: ToolCallStatus = 'executing'): ToolCallData {
return { id, toolName: 'read', displayTitle: `Reading ${id}`, status }
}

function items(tools: ToolCallData[]): AgentGroupItem[] {
return tools.map((data) => ({ type: 'tool', data }))
}

describe.each(['mothership', 'workflow', 'browser'])('%s activity cadence', (agentName) => {
let root: Root
let container: HTMLDivElement
beforeEach(() => {
vi.useFakeTimers()
;(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()
vi.useRealTimers()
})
const render = (tools: ToolCallData[], active = true) =>
act(() =>
root.render(
<AgentGroup
agentName={agentName}
agentLabel='Agent prefix'
items={items(tools)}
isStreaming={active}
isLaneOpen={active}
/>
)
)
const header = () => container.querySelector('[role="status"]')
const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms))

it('shows the first action immediately and coalesces bursts without replaying a backlog', () => {
render([])
advance(100)
render([tool('first')])
expect(header()?.textContent).toBe('Reading first')
const row = header()
const shimmer = container.querySelector('[class*="shimmer"]')
advance(100)
render([tool('first', 'success')])
expect(header()?.textContent).toBe('Reading first')
expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
render([tool('first', 'success'), tool('second')])
expect(header()).toBe(row)
expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
expect(header()?.textContent).toBe('Reading first')
advance(600)
render([tool('first', 'success'), tool('second', 'success'), tool('third')])
advance(299)
expect(header()?.textContent).toBe('Reading first')
advance(1)
expect(header()?.textContent).toBe('Reading third')
expect(container.textContent).not.toContain('Agent prefix')
advance(1000)
expect(header()?.textContent).toBe('Reading third')
})

it('keeps live history complete under a stable expanded header with keyboard disclosure', () => {
render([tool('first', 'success'), tool('second')])
const trigger = container.querySelector<HTMLElement>('[role="button"]')!
const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
act(() => trigger.dispatchEvent(event))
expect(event.defaultPrevented).toBe(true)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(header()?.textContent).toBe('Tool activity')
render([tool('first', 'success'), tool('second', 'success'), tool('third')])
expect(header()?.textContent).toBe('Tool activity')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
'Read firstRead secondReading third'
)
act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(header()?.textContent).toBe('Reading third')
})

it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
'shows %s immediately and cancels a pending cosmetic update',
(status) => {
render([tool('first')])
advance(100)
render([tool('first', 'success'), tool('second')])
render([tool('first', 'success'), tool('second', status)])
const prefix =
status === 'error' || status === 'rejected'
? 'Failed'
: status === 'skipped'
? 'Skipped'
: 'Stopped'
expect(header()?.textContent).toBe(`${prefix} reading second`)
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
advance(1500)
expect(header()?.textContent).toBe(`${prefix} reading second`)
}
)

it('shows final completion immediately and never replays the held action', () => {
render([tool('first')])
advance(100)
render([tool('first', 'success'), tool('second')])
render([tool('first', 'success'), tool('second', 'success')], false)
expect(header()?.textContent).toBe('Read files')
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
advance(2000)
expect(header()?.textContent).toBe('Read files')
})

it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
'keeps an earlier parallel call active when the latest one becomes %s',
(status) => {
render([tool('first'), tool('second')])
advance(100)
render([tool('first'), tool('second', status)])
const outcome =
status === 'error' || status === 'rejected'
? 'failed'
: status === 'skipped'
? 'skipped'
: 'stopped'
expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
advance(1000)
expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
}
)

it('surfaces an earlier parallel failure while the latest call keeps working', () => {
render([tool('first'), tool('second')])
advance(100)
render([tool('first', 'error'), tool('second')])
expect(header()?.textContent).toBe('Reading second · 1 failed')
})

it('keeps narration from prematurely completing an open lane', () => {
act(() =>
root.render(
<AgentGroup
agentName={agentName}
agentLabel='Sim'
isStreaming
isLaneOpen
items={[
...items([tool('first', 'success')]),
{ type: 'text', content: 'Checking another source.' },
...items([tool('second', 'success')]),
]}
/>
)
)
const rows = container.querySelectorAll('[role="status"]')
if (agentName === 'mothership') {
expect(rows[0].textContent).toBe('Read first')
expect(rows[0].querySelector('[class*="shimmer"]')).toBeNull()
}
const liveRow = rows[rows.length - 1]
expect(liveRow.textContent).toBe('Reading second')
expect(liveRow.querySelector('[class*="shimmer"]')).not.toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client'

import { useEffect, useState } from 'react'
import { ActivityStatus, type ActivityStatusProps } from '@/components/ui/activity-status'
import {
ActivityDisclosure,
type ActivityDisclosureProps,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'

const ACTIVITY_UPDATE_INTERVAL_MS = 1000

interface ActivityStreamProps extends Omit<ActivityDisclosureProps, 'header'> {
activity: ActivityStatusProps
activityKey?: string
attentionKey: string
collapsible: boolean
}

/** Pace only the header; history and interactive tool state continue updating immediately. */
export function ActivityStream({
activity,
activityKey,
attentionKey,
expanded,
collapsible,
children,
onToggle,
isStreaming,
unbounded,
}: ActivityStreamProps) {
const isExpanded = collapsible && expanded
const key = `${activityKey}:${activity.label}`
const resetKey = `${activity.isActive}:${isExpanded}:${Boolean(activityKey)}:${attentionKey}`
const [visible, setVisible] = useState(() => ({
activity,
key,
resetKey,
shownAt: Date.now(),
}))

/** Completion, attention, and disclosure changes bypass the cosmetic delay. */
if (visible.resetKey !== resetKey) {
setVisible({ activity, key, resetKey, shownAt: Date.now() })
}

useEffect(() => {
if (!activity.isActive || isExpanded || key === visible.key) return
const remaining = Math.max(0, ACTIVITY_UPDATE_INTERVAL_MS - (Date.now() - visible.shownAt))
const flush = () => setVisible({ activity, key, resetKey, shownAt: Date.now() })
if (remaining === 0) {
flush()
return
}
const timer = setTimeout(flush, remaining)
return () => clearTimeout(timer)
}, [activity, key, resetKey, isExpanded, visible.key, visible.shownAt])

const displayed =
!activity.isActive || isExpanded || key === visible.key ? activity : visible.activity
const header = (
<ActivityStatus
{...displayed}
label={isExpanded && activity.isActive ? 'Tool activity' : displayed.label}
isActive={activity.isActive}
/>
)
return (
<ActivityDisclosure
header={header}
collapsible={collapsible}
expanded={expanded}
onToggle={onToggle}
isStreaming={isStreaming}
unbounded={unbounded}
>
{children}
</ActivityDisclosure>
)
}
Loading
Loading