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 @@ -154,7 +154,7 @@ describe('ClientChatMessage thinking chrome (Step 6)', () => {
})
mounts.push(unmount)

expect(container.textContent).toContain('Thinking…')
expect(container.textContent).toContain('Thinking')
expect(container.textContent).toContain('Internal reasoning')
expect(container.textContent).toContain('Answer text')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ describe('HeroChatLoop production thinking handoff', () => {
})

it.each([
['thinking', 'Thinking…'],
['thinking', 'Thinking'],
['dispatching', 'Dispatching…'],
] as const)('shows the production activity indicator alone during %s', (phase, label) => {
renderPhase(phase)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export function HeroChatLoop({
)}
>
{showThinking && (
<PendingTagIndicator label={phase === 'dispatching' ? 'Dispatching…' : 'Thinking…'} />
<PendingTagIndicator label={phase === 'dispatching' ? 'Dispatching…' : 'Thinking'} />
)}
{showBuilding && (
<AgentGroupView
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
'use client'

import { type ReactNode, useEffect, useLayoutEffect, useRef } from 'react'
import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
import {
cn,
overflowFadeSizeClass,
scrollFadeAttributes,
scrollFadeClass,
useScrollEdges,
} from '@sim/emcn'

interface ActivityViewportProps {
children: ReactNode
Expand Down Expand Up @@ -85,7 +91,8 @@ export function ActivityViewport({
className={cn(
'pr-2',
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
scrollFadeClass
scrollFadeClass,
overflowFadeSizeClass
)}
{...scrollFadeAttributes(edges)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ export function AgentGroupView({
liveToolId={liveCall?.id}
/>
) : (
<div className='flex min-w-0 flex-col gap-2'>{items.map(renderItem)}</div>
<div className='flex min-w-0 flex-col gap-2 [&>*:has(+[data-interaction-card])]:mb-2 [&>[data-interaction-card]:not(:last-child)]:mb-2'>
{items.map(renderItem)}
</div>
)
const headerText = error
? agentLabel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,9 @@ export function MainAgentActivity({
)
})

return <div className='flex min-w-0 flex-col gap-2'>{activity}</div>
return (
<div className='flex min-w-0 flex-col gap-2 [&>*:has(+[data-interaction-card])]:mb-2 [&>[data-interaction-card]:not(:last-child)]:mb-2'>
{activity}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
chipRadiusClass,
cn,
OverflowText,
overflowFadeSizeClass,
scrollFadeAttributes,
scrollFadeClass,
useScrollEdges,
Expand Down Expand Up @@ -40,7 +41,11 @@ export function SearchActivityResults({ sources, label }: SearchActivityResultsP
ref={scrollRef}
role='region'
aria-label={label}
className={cn('max-h-[152px] overflow-y-auto overscroll-contain p-1', scrollFadeClass)}
className={cn(
'max-h-[152px] overflow-y-auto overscroll-contain p-1',
scrollFadeClass,
overflowFadeSizeClass
)}
{...scrollFadeAttributes(edges)}
>
<ul className='m-0 list-none p-0'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
cn,
Lightbox,
languages,
overflowFadeSizeClass,
highlight as prismHighlight,
scrollFadeAttributes,
scrollFadeXClass,
Expand Down Expand Up @@ -287,7 +288,8 @@ function MarkdownTable({ children }: MarkdownTableProps) {
tabIndex={isOverflowing ? 0 : undefined}
className={cn(
'not-prose my-4 w-full overflow-x-auto [&_strong]:font-semibold',
scrollFadeXClass
scrollFadeXClass,
overflowFadeSizeClass
)}
{...scrollFadeAttributes(edges)}
>
Expand Down Expand Up @@ -754,7 +756,7 @@ function ChatContentInner({
<WorkspaceRefsContext.Provider
value={{ resources: workspaceRefs, onSelect: onWorkspaceResourceSelect }}
>
<div className={cn('space-y-3', inter.className)}>
<div className={cn('space-y-4', inter.className)}>
{groups.map((group, i) => {
if (group.kind === 'inline') {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface InteractionCardProps {
export function InteractionCard({ children, title, actions, className }: InteractionCardProps) {
return (
<div
data-interaction-card
className={cn(
'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
className
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ export function WatchActivity({ task: recorded }: WatchActivityProps) {
: recorded
const pending = task.status === undefined || task.status === 'pending'
return (
<div aria-busy={pending} title={[task.summary, task.note].filter(Boolean).join('\n')}>
<div
data-chat-activity
aria-busy={pending}
title={[task.summary, task.note].filter(Boolean).join('\n')}
>
<ActivityStatus label={watchLabel(task, params?.workspaceId)} isActive={pending} />
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/** @vitest-environment jsdom */
import { act, type ComponentProps } 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 } from '@/app/workspace/[workspaceId]/home/types'

vi.mock('@/lib/auth/auth-client', () => ({
useSession: () => ({ data: null, isPending: false }),
}))
vi.mock('@/hooks/use-smooth-text', () => ({ useSmoothText: (text: string) => text }))
vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-test' }),
useRouter: () => ({ prefetch: vi.fn(), push: vi.fn() }),
}))

const QUESTION = `<question>${JSON.stringify([
{
type: 'single_select',
prompt: 'Choose an account',
options: [
{ id: 'personal', label: 'Personal account' },
{ id: 'team', label: 'Team account' },
],
},
])}</question>`

function activity(id: string): ContentBlock {
return {
type: 'tool_call',
spanId: 'main',
toolCall: {
id,
name: 'read',
status: 'success',
params: { activity: { id, title: `Reading ${id}`, completedTitle: `Read ${id}` } },
},
}
}

function text(content: string): ContentBlock {
return { type: 'text', content }
}

/**
* These render tests protect the adjacency and card boundaries consumed by the
* spacing selectors. Pixel gaps are checked in the browser; jsdom has no layout.
*/
describe('message activity and card boundaries', () => {
let container: HTMLDivElement
let root: Root
let queryClient: QueryClient
const onSelect = vi.fn()
const onDismiss = vi.fn()

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

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

async function render(
blocks: ContentBlock[],
props: Partial<ComponentProps<typeof MessageContent>> = {}
) {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MessageContent
blocks={blocks}
fallbackContent=''
isStreaming={false}
onOptionSelect={onSelect}
onQuestionDismiss={onDismiss}
{...props}
/>
</QueryClientProvider>
)
})
}

function activities() {
return [...container.querySelectorAll<HTMLElement>('[data-chat-activity]')]
}

function card() {
const element = container.querySelector<HTMLElement>('[data-interaction-card]')
expect(element).not.toBeNull()
return element!
}

function button(label: string) {
const element = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(node) => node.textContent === label || node.getAttribute('aria-label') === label
)
expect(element).toBeDefined()
return element!
}

it('keeps consecutive activity rows adjacent and prose on its own boundary', async () => {
await render([
text('Starting the review.'),
activity('first'),
activity('second'),
text('Done.'),
])
const [first, second] = activities()
expect(activities()).toHaveLength(2)
expect(first.nextElementSibling).toBe(second)
expect(first.previousElementSibling?.textContent).toBe('Starting the review.')
expect(second.nextElementSibling?.textContent).toBe('Done.')
expect(first.parentElement).toBe(second.parentElement)
expect(first.previousElementSibling?.hasAttribute('data-chat-activity')).toBe(false)
expect(second.nextElementSibling?.hasAttribute('data-chat-activity')).toBe(false)
})

it.each(['active', 'answered'] as const)(
'keeps a rehydrated %s card between the same activity boundaries',
async (state) => {
await render([activity('first'), text(QUESTION), activity('second')], {
questionAnswers: state === 'answered' ? ['Personal account'] : undefined,
})
const [first, second] = activities()
const cardRoot = card().parentElement!
expect(first.nextElementSibling).toBe(cardRoot)
expect(cardRoot.nextElementSibling).toBe(second)
expect(cardRoot.lastElementChild).toBe(card())
expect(cardRoot.children).toHaveLength(1)
expect(card().textContent).toContain('Choose an account')
expect(card().querySelector('input') !== null).toBe(state === 'active')
}
)

it('answers in place without remounting adjacent tool activity', async () => {
await render([activity('first'), text(QUESTION), activity('second')])
const [first, second] = activities()
const cardRoot = card().parentElement!
act(() => button('Personal account').click())
expect(onSelect).toHaveBeenCalledWith('Choose an account — Personal account')
expect(card().textContent).toContain('Personal account')
expect(card().querySelector('input')).toBeNull()
expect(card().parentElement).toBe(cardRoot)
expect(activities()[0]).toBe(first)
expect(activities()[1]).toBe(second)
expect(first.nextElementSibling).toBe(cardRoot)
expect(cardRoot.nextElementSibling).toBe(second)
})

it('leaves only an empty boundary when a card between activities is dismissed', async () => {
await render([activity('first'), text(QUESTION), activity('second')])
const [first, second] = activities()
const cardRoot = card().parentElement!
act(() => button('Dismiss').click())
expect(onDismiss).toHaveBeenCalledOnce()
expect(container.querySelector('[data-interaction-card]')).toBeNull()
expect(cardRoot.matches(':empty')).toBe(true)
expect(first.nextElementSibling).toBe(cardRoot)
expect(cardRoot.nextElementSibling).toBe(second)
expect(activities()[0]).toBe(first)
expect(activities()[1]).toBe(second)
})

it('keeps the terminal action region outside the empty dismissed-card boundary', async () => {
await render([activity('first'), text(QUESTION)], {
actions: <button type='button'>Copy</button>,
})
const first = activities()[0]
const cardRoot = card().parentElement!
const actionRegion = button('Copy').parentElement!.parentElement!
const stack = first.parentElement!
expect(stack.nextElementSibling).toBe(actionRegion)
act(() => button('Dismiss').click())
expect(cardRoot.matches(':empty')).toBe(true)
expect(cardRoot.nextElementSibling).toBeNull()
expect(first.nextElementSibling).toBe(cardRoot)
expect(stack.nextElementSibling).toBe(actionRegion)
expect(stack.contains(button('Copy'))).toBe(false)
})

it('keeps a trailing activity recap on the card boundary before the next activity', async () => {
const takeover: ContentBlock = {
type: 'tool_call',
spanId: 'main',
toolCall: {
id: 'takeover',
name: 'browser_request_takeover',
status: 'success',
params: { reason: 'Review the browser step' },
result: { success: true, output: { userInstruction: 'Continue' } },
},
}
await render([activity('first'), takeover, activity('second')])
const [first, second] = activities()
expect(first.nextElementSibling).toBe(second)
expect(first.lastElementChild?.lastElementChild?.lastElementChild).toBe(card())
expect(card().nextElementSibling).toBeNull()
})

it('does not treat a card followed by prose as the final content of its segment', async () => {
await render([text(`${QUESTION}\n\nThe draft is ready.`), activity('next')])
const cardRoot = card().parentElement!
expect(cardRoot.lastElementChild).not.toBe(card())
expect(cardRoot.lastElementChild?.textContent).toBe('The draft is ready.')
expect(cardRoot.nextElementSibling).toBe(activities()[0])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -1241,14 +1241,14 @@ describe('parseBlocks main activity controls', () => {

describe('deriveThinkingLabel', () => {
it('maps the most recent block to an activity phrase', () => {
expect(deriveThinkingLabel([])).toBe('Thinking…')
expect(deriveThinkingLabel([])).toBe('Thinking')
expect(deriveThinkingLabel([{ type: 'thinking', content: 'hm', timestamp: 1 }])).toBe(
'Thinking…'
'Thinking'
)
// A stall after streamed text is the agent deciding what's next, not generating.
expect(deriveThinkingLabel([mainText('hi')])).toBe('Thinking…')
expect(deriveThinkingLabel([mainText('hi')])).toBe('Thinking')
expect(deriveThinkingLabel([{ type: 'subagent_text', content: 'x', timestamp: 1 }])).toBe(
'Thinking…'
'Thinking'
)
expect(deriveThinkingLabel([{ type: 'subagent_end', spanId: 'S1', timestamp: 1 }])).toBe(
'Returning…'
Expand All @@ -1258,8 +1258,8 @@ describe('deriveThinkingLabel', () => {
it('shows Dispatching for the dispatch call, then yields to the opened lane', () => {
expect(deriveThinkingLabel([mainToolCall('t1', 'workflow')])).toBe('Dispatching…')
expect(deriveThinkingLabel([mainToolCall('t1', 'prepare_file_edit')])).toBe('Dispatching…')
expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking…')
expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBe('Thinking…')
expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking')
expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBe('Thinking')
})
})

Expand Down
Loading
Loading