1+ /** @jest -environment jsdom */
2+
3+ /// <reference types="node" />
4+
5+ import { afterEach , describe , expect , it , jest } from "@jest/globals" ;
6+ import { act } from "react-dom/test-utils" ;
7+ import { createRoot } from "react-dom/client" ;
8+
9+ import { AssistantMessage } from "../sidepanel/components/AssistantMessage" ;
10+ import type { ChatMessage } from "../sidepanel/types" ;
11+
12+ jest . mock ( "../sidepanel/components/AssistantStatusCard" , ( ) => ( {
13+ AssistantStatusCard : ( ) => null ,
14+ } ) ) ;
15+
16+ jest . mock ( "../sidepanel/components/IconButton" , ( ) => ( {
17+ IconButton : ( {
18+ children,
19+ onClick,
20+ } : {
21+ children : React . ReactNode ;
22+ onClick ?: ( ) => void ;
23+ } ) => {
24+ const React = require ( "react" ) ;
25+ return React . createElement ( "button" , { type : "button" , onClick } , children ) ;
26+ } ,
27+ } ) ) ;
28+
29+ jest . mock ( "../sidepanel/components/LinkCardsBlock" , ( ) => ( {
30+ LinkCardsBlock : ( ) => null ,
31+ } ) ) ;
32+
33+ jest . mock ( "../sidepanel/components/MarkdownContent" , ( ) => ( {
34+ MarkdownContent : ( { text } : { text : string } ) => {
35+ const React = require ( "react" ) ;
36+ return React . createElement ( "div" , null , text ) ;
37+ } ,
38+ } ) ) ;
39+
40+ jest . mock ( "../sidepanel/components/MessageFooter" , ( ) => ( {
41+ MessageFooter : ( { children } : { children : React . ReactNode } ) => {
42+ const React = require ( "react" ) ;
43+ return React . createElement ( "div" , null , children ) ;
44+ } ,
45+ } ) ) ;
46+
47+ jest . mock ( "../sidepanel/components/ReasoningBlock" , ( ) => ( {
48+ ReasoningBlock : ( { text } : { text : string } ) => {
49+ const React = require ( "react" ) ;
50+ return React . createElement ( "div" , null , text ) ;
51+ } ,
52+ } ) ) ;
53+
54+ jest . mock ( "../sidepanel/components/ToolCallBlock" , ( ) => ( {
55+ ToolCallBlock : ( ) => null ,
56+ } ) ) ;
57+
58+ jest . mock ( "../i18n" , ( ) => ( {
59+ useI18n : ( ) => ( {
60+ t : ( key : string ) => key ,
61+ } ) ,
62+ } ) ) ;
63+
64+ (
65+ globalThis as typeof globalThis & {
66+ IS_REACT_ACT_ENVIRONMENT ?: boolean ;
67+ }
68+ ) . IS_REACT_ACT_ENVIRONMENT = true ;
69+
70+ function renderAssistantMessage (
71+ props : Partial < React . ComponentProps < typeof AssistantMessage > > = { }
72+ ) {
73+ const container = document . createElement ( "div" ) ;
74+ document . body . appendChild ( container ) ;
75+ const root = createRoot ( container ) ;
76+ const message : ChatMessage = {
77+ id : "assistant-1" ,
78+ role : "assistant" ,
79+ parts : [ ] ,
80+ status : "running" ,
81+ } ;
82+
83+ act ( ( ) => {
84+ root . render (
85+ < AssistantMessage
86+ isLast = { true }
87+ isRunning = { true }
88+ message = { message }
89+ thinkingMode = { false }
90+ { ...props }
91+ />
92+ ) ;
93+ } ) ;
94+
95+ return {
96+ container,
97+ cleanup : ( ) => {
98+ act ( ( ) => root . unmount ( ) ) ;
99+ container . remove ( ) ;
100+ } ,
101+ } ;
102+ }
103+
104+ describe ( "AssistantMessage" , ( ) => {
105+ afterEach ( ( ) => {
106+ document . body . innerHTML = "" ;
107+ } ) ;
108+
109+ it ( "shows a preparing response indicator before assistant text arrives" , ( ) => {
110+ const { container, cleanup } = renderAssistantMessage ( ) ;
111+ const indicator = container . querySelector ( '[role="status"]' ) ;
112+
113+ expect ( indicator ?. getAttribute ( "aria-label" ) ) . toBe ( "common.loading" ) ;
114+ expect ( container . querySelectorAll ( ".claude-dot" ) ) . toHaveLength ( 3 ) ;
115+ expect ( indicator ?. className ) . not . toContain ( "rounded-full" ) ;
116+ expect ( indicator ?. className ) . not . toContain ( "border" ) ;
117+
118+ cleanup ( ) ;
119+ } ) ;
120+
121+ it ( "keeps the preparing indicator visible for a leading step-start part" , ( ) => {
122+ const { container, cleanup } = renderAssistantMessage ( {
123+ message : {
124+ id : "assistant-2" ,
125+ role : "assistant" ,
126+ parts : [ { type : "step-start" } ] ,
127+ status : "running" ,
128+ } ,
129+ } ) ;
130+
131+ expect ( container . querySelector ( '[role="status"]' ) ) . not . toBeNull ( ) ;
132+ expect ( container . querySelectorAll ( ".claude-dot" ) ) . toHaveLength ( 3 ) ;
133+
134+ cleanup ( ) ;
135+ } ) ;
136+
137+ it ( "keeps the preparing indicator visible while reasoning is streaming" , ( ) => {
138+ const { container, cleanup } = renderAssistantMessage ( {
139+ message : {
140+ id : "assistant-3" ,
141+ role : "assistant" ,
142+ parts : [ { type : "reasoning" , text : "Thinking" , streaming : true } ] ,
143+ status : "running" ,
144+ } ,
145+ thinkingMode : true ,
146+ } ) ;
147+
148+ expect ( container . querySelector ( '[role="status"]' ) ) . not . toBeNull ( ) ;
149+ expect ( container . textContent ) . toContain ( "Thinking" ) ;
150+
151+ cleanup ( ) ;
152+ } ) ;
153+
154+ it ( "keeps the preparing indicator visible while a tool call is running" , ( ) => {
155+ const { container, cleanup } = renderAssistantMessage ( {
156+ message : {
157+ id : "assistant-4" ,
158+ role : "assistant" ,
159+ parts : [
160+ {
161+ type : "tool-call" ,
162+ toolCallId : "tool-1" ,
163+ toolName : "search_web" ,
164+ args : { query : "huntly" } ,
165+ } ,
166+ ] ,
167+ status : "running" ,
168+ } ,
169+ } ) ;
170+
171+ expect ( container . querySelector ( '[role="status"]' ) ) . not . toBeNull ( ) ;
172+
173+ cleanup ( ) ;
174+ } ) ;
175+
176+ it ( "shows the preparing indicator again after earlier text when a tool call starts" , ( ) => {
177+ const { container, cleanup } = renderAssistantMessage ( {
178+ message : {
179+ id : "assistant-5" ,
180+ role : "assistant" ,
181+ parts : [
182+ { type : "text" , text : "先给你一个结论。" } ,
183+ {
184+ type : "tool-call" ,
185+ toolCallId : "tool-2" ,
186+ toolName : "search_web" ,
187+ args : { query : "huntly" } ,
188+ } ,
189+ ] ,
190+ status : "running" ,
191+ } ,
192+ } ) ;
193+
194+ expect ( container . querySelector ( '[role="status"]' ) ) . not . toBeNull ( ) ;
195+
196+ cleanup ( ) ;
197+ } ) ;
198+
199+ it ( "shows the preparing indicator again after earlier text when a new step starts" , ( ) => {
200+ const { container, cleanup } = renderAssistantMessage ( {
201+ message : {
202+ id : "assistant-6" ,
203+ role : "assistant" ,
204+ parts : [
205+ { type : "text" , text : "先给你一个结论。" } ,
206+ { type : "step-start" } ,
207+ ] ,
208+ status : "running" ,
209+ } ,
210+ } ) ;
211+
212+ expect ( container . querySelector ( '[role="status"]' ) ) . not . toBeNull ( ) ;
213+
214+ cleanup ( ) ;
215+ } ) ;
216+
217+ it ( "hides the preparing indicator once visible text arrives" , ( ) => {
218+ const { container, cleanup } = renderAssistantMessage ( {
219+ message : {
220+ id : "assistant-7" ,
221+ role : "assistant" ,
222+ parts : [ { type : "text" , text : "hello" } ] ,
223+ status : "running" ,
224+ } ,
225+ } ) ;
226+
227+ expect ( container . querySelector ( '[role="status"]' ) ) . toBeNull ( ) ;
228+
229+ cleanup ( ) ;
230+ } ) ;
231+ } ) ;
0 commit comments