Skip to content

Commit 04ba348

Browse files
committed
chore(chat): cover interaction spacing boundaries
1 parent 3a37ae1 commit 04ba348

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/** @vitest-environment jsdom */
2+
import { act, type ComponentProps } from 'react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { createRoot, type Root } from 'react-dom/client'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { MessageContent } from '@/app/workspace/[workspaceId]/home/components/message-content/message-content'
7+
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
8+
9+
vi.mock('@/lib/auth/auth-client', () => ({
10+
useSession: () => ({ data: null, isPending: false }),
11+
}))
12+
vi.mock('@/hooks/use-smooth-text', () => ({ useSmoothText: (text: string) => text }))
13+
vi.mock('next/navigation', () => ({
14+
useParams: () => ({ workspaceId: 'workspace-test' }),
15+
useRouter: () => ({ prefetch: vi.fn(), push: vi.fn() }),
16+
}))
17+
18+
const QUESTION = `<question>${JSON.stringify([
19+
{
20+
type: 'single_select',
21+
prompt: 'Choose an account',
22+
options: [
23+
{ id: 'personal', label: 'Personal account' },
24+
{ id: 'team', label: 'Team account' },
25+
],
26+
},
27+
])}</question>`
28+
29+
function activity(id: string): ContentBlock {
30+
return {
31+
type: 'tool_call',
32+
spanId: 'main',
33+
toolCall: {
34+
id,
35+
name: 'read',
36+
status: 'success',
37+
params: { activity: { id, title: `Reading ${id}`, completedTitle: `Read ${id}` } },
38+
},
39+
}
40+
}
41+
42+
function text(content: string): ContentBlock {
43+
return { type: 'text', content }
44+
}
45+
46+
/**
47+
* These render tests protect the adjacency and card boundaries consumed by the
48+
* spacing selectors. Pixel gaps are checked in the browser; jsdom has no layout.
49+
*/
50+
describe('message activity and card boundaries', () => {
51+
let container: HTMLDivElement
52+
let root: Root
53+
let queryClient: QueryClient
54+
const onSelect = vi.fn()
55+
const onDismiss = vi.fn()
56+
57+
beforeEach(() => {
58+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
59+
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false }))
60+
vi.clearAllMocks()
61+
queryClient = new QueryClient()
62+
container = document.createElement('div')
63+
document.body.appendChild(container)
64+
root = createRoot(container)
65+
})
66+
67+
afterEach(() => {
68+
act(() => root.unmount())
69+
container.remove()
70+
queryClient.clear()
71+
vi.unstubAllGlobals()
72+
})
73+
74+
async function render(
75+
blocks: ContentBlock[],
76+
props: Partial<ComponentProps<typeof MessageContent>> = {}
77+
) {
78+
await act(async () => {
79+
root.render(
80+
<QueryClientProvider client={queryClient}>
81+
<MessageContent
82+
blocks={blocks}
83+
fallbackContent=''
84+
isStreaming={false}
85+
onOptionSelect={onSelect}
86+
onQuestionDismiss={onDismiss}
87+
{...props}
88+
/>
89+
</QueryClientProvider>
90+
)
91+
})
92+
}
93+
94+
function activities() {
95+
return [...container.querySelectorAll<HTMLElement>('[data-chat-activity]')]
96+
}
97+
98+
function card() {
99+
const element = container.querySelector<HTMLElement>('[data-interaction-card]')
100+
expect(element).not.toBeNull()
101+
return element!
102+
}
103+
104+
function button(label: string) {
105+
const element = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
106+
(node) => node.textContent === label || node.getAttribute('aria-label') === label
107+
)
108+
expect(element).toBeDefined()
109+
return element!
110+
}
111+
112+
it('keeps consecutive activity rows adjacent and prose on its own boundary', async () => {
113+
await render([
114+
text('Starting the review.'),
115+
activity('first'),
116+
activity('second'),
117+
text('Done.'),
118+
])
119+
const [first, second] = activities()
120+
expect(activities()).toHaveLength(2)
121+
expect(first.nextElementSibling).toBe(second)
122+
expect(first.previousElementSibling?.textContent).toBe('Starting the review.')
123+
expect(second.nextElementSibling?.textContent).toBe('Done.')
124+
expect(first.parentElement).toBe(second.parentElement)
125+
expect(first.previousElementSibling?.hasAttribute('data-chat-activity')).toBe(false)
126+
expect(second.nextElementSibling?.hasAttribute('data-chat-activity')).toBe(false)
127+
})
128+
129+
it.each(['active', 'answered'] as const)(
130+
'keeps a rehydrated %s card between the same activity boundaries',
131+
async (state) => {
132+
await render([activity('first'), text(QUESTION), activity('second')], {
133+
questionAnswers: state === 'answered' ? ['Personal account'] : undefined,
134+
})
135+
const [first, second] = activities()
136+
const cardRoot = card().parentElement!
137+
expect(first.nextElementSibling).toBe(cardRoot)
138+
expect(cardRoot.nextElementSibling).toBe(second)
139+
expect(cardRoot.lastElementChild).toBe(card())
140+
expect(cardRoot.children).toHaveLength(1)
141+
expect(card().textContent).toContain('Choose an account')
142+
expect(card().querySelector('input') !== null).toBe(state === 'active')
143+
}
144+
)
145+
146+
it('answers in place without remounting adjacent tool activity', async () => {
147+
await render([activity('first'), text(QUESTION), activity('second')])
148+
const [first, second] = activities()
149+
const cardRoot = card().parentElement!
150+
act(() => button('Personal account').click())
151+
expect(onSelect).toHaveBeenCalledWith('Choose an account — Personal account')
152+
expect(card().textContent).toContain('Personal account')
153+
expect(card().querySelector('input')).toBeNull()
154+
expect(card().parentElement).toBe(cardRoot)
155+
expect(activities()[0]).toBe(first)
156+
expect(activities()[1]).toBe(second)
157+
expect(first.nextElementSibling).toBe(cardRoot)
158+
expect(cardRoot.nextElementSibling).toBe(second)
159+
})
160+
161+
it('leaves only an empty boundary when a card between activities is dismissed', async () => {
162+
await render([activity('first'), text(QUESTION), activity('second')])
163+
const [first, second] = activities()
164+
const cardRoot = card().parentElement!
165+
act(() => button('Dismiss').click())
166+
expect(onDismiss).toHaveBeenCalledOnce()
167+
expect(container.querySelector('[data-interaction-card]')).toBeNull()
168+
expect(cardRoot.matches(':empty')).toBe(true)
169+
expect(first.nextElementSibling).toBe(cardRoot)
170+
expect(cardRoot.nextElementSibling).toBe(second)
171+
expect(activities()[0]).toBe(first)
172+
expect(activities()[1]).toBe(second)
173+
})
174+
175+
it('keeps the terminal action region outside the empty dismissed-card boundary', async () => {
176+
await render([activity('first'), text(QUESTION)], {
177+
actions: <button type='button'>Copy</button>,
178+
})
179+
const first = activities()[0]
180+
const cardRoot = card().parentElement!
181+
const actionRegion = button('Copy').parentElement!.parentElement!
182+
const stack = first.parentElement!
183+
expect(stack.nextElementSibling).toBe(actionRegion)
184+
act(() => button('Dismiss').click())
185+
expect(cardRoot.matches(':empty')).toBe(true)
186+
expect(cardRoot.nextElementSibling).toBeNull()
187+
expect(first.nextElementSibling).toBe(cardRoot)
188+
expect(stack.nextElementSibling).toBe(actionRegion)
189+
expect(stack.contains(button('Copy'))).toBe(false)
190+
})
191+
192+
it('keeps a trailing activity recap on the card boundary before the next activity', async () => {
193+
const takeover: ContentBlock = {
194+
type: 'tool_call',
195+
spanId: 'main',
196+
toolCall: {
197+
id: 'takeover',
198+
name: 'browser_request_takeover',
199+
status: 'success',
200+
params: { reason: 'Review the browser step' },
201+
result: { success: true, output: { userInstruction: 'Continue' } },
202+
},
203+
}
204+
await render([activity('first'), takeover, activity('second')])
205+
const [first, second] = activities()
206+
expect(first.nextElementSibling).toBe(second)
207+
expect(first.lastElementChild?.lastElementChild?.lastElementChild).toBe(card())
208+
expect(card().nextElementSibling).toBeNull()
209+
})
210+
211+
it('does not treat a card followed by prose as the final content of its segment', async () => {
212+
await render([text(`${QUESTION}\n\nThe draft is ready.`), activity('next')])
213+
const cardRoot = card().parentElement!
214+
expect(cardRoot.lastElementChild).not.toBe(card())
215+
expect(cardRoot.lastElementChild?.textContent).toBe('The draft is ready.')
216+
expect(cardRoot.nextElementSibling).toBe(activities()[0])
217+
})
218+
})

0 commit comments

Comments
 (0)