1- import { beforeEach , describe , it , expect , vi } from "vitest" ;
2- import { render , screen } from "@testing-library/react" ;
1+ import { afterEach , beforeEach , describe , it , expect , vi } from "vitest" ;
2+ import { act , fireEvent , render , screen } from "@testing-library/react" ;
33import userEvent from "@testing-library/user-event" ;
44import { MessageBubble } from "../MessageBubble" ;
55import { useAgentStore } from "@/features/agents/stores/agentStore" ;
66import type { Message } from "@/shared/types/messages" ;
77import { openPath } from "@tauri-apps/plugin-opener" ;
8+ const mockWriteText = vi . fn ( ) . mockResolvedValue ( undefined ) ;
9+ vi . mock ( "@tauri-apps/plugin-opener" , ( ) => ( {
10+ openPath : vi . fn ( ) ,
11+ } ) ) ;
812
913// ── helpers ───────────────────────────────────────────────────────────
1014
@@ -37,6 +41,17 @@ describe("MessageBubble", () => {
3741 beforeEach ( ( ) => {
3842 useAgentStore . setState ( { personas : [ ] } ) ;
3943 vi . mocked ( openPath ) . mockClear ( ) ;
44+ mockWriteText . mockClear ( ) ;
45+ Object . defineProperty ( navigator , "clipboard" , {
46+ configurable : true ,
47+ value : {
48+ writeText : mockWriteText ,
49+ } ,
50+ } ) ;
51+ } ) ;
52+
53+ afterEach ( ( ) => {
54+ vi . useRealTimers ( ) ;
4055 } ) ;
4156
4257 it ( "renders user message with correct alignment" , ( ) => {
@@ -66,6 +81,18 @@ describe("MessageBubble", () => {
6681 expect ( screen . getByText ( "hello world" ) ) . toBeInTheDocument ( ) ;
6782 } ) ;
6883
84+ it ( "renders user text inside a muted bubble shell" , ( ) => {
85+ const { container } = render (
86+ < MessageBubble message = { userMessage ( "hello world" ) } /> ,
87+ ) ;
88+
89+ expect (
90+ container . querySelector (
91+ '[data-role="user-message"] .rounded-2xl.bg-muted' ,
92+ ) ,
93+ ) . toBeInTheDocument ( ) ;
94+ } ) ;
95+
6996 it ( "renders multiple content blocks" , ( ) => {
7097 const msg = assistantMessage ( [
7198 { type : "text" , text : "first block" } ,
@@ -76,16 +103,105 @@ describe("MessageBubble", () => {
76103 expect ( screen . getByText ( "second block" ) ) . toBeInTheDocument ( ) ;
77104 } ) ;
78105
79- it ( "shows action buttons on hover (retry for assistant) " , ( ) => {
106+ it ( "renders a reserved actions tray for assistant messages " , ( ) => {
80107 const onRetryMessage = vi . fn ( ) ;
81- render (
108+ const { container } = render (
82109 < MessageBubble
83110 message = { assistantMessage ( [ { type : "text" , text : "response" } ] ) }
84111 onRetryMessage = { onRetryMessage }
85112 /> ,
86113 ) ;
87- const retryBtn = screen . getByRole ( "button" , { name : / r e t r y / i } ) ;
88- expect ( retryBtn ) . toBeInTheDocument ( ) ;
114+
115+ expect (
116+ container . querySelector ( '[data-role="assistant-message"] .pb-8' ) ,
117+ ) . toBeInTheDocument ( ) ;
118+ expect (
119+ container . querySelector (
120+ '[data-role="assistant-message"] [data-role="message-actions"]' ,
121+ ) ,
122+ ) . toBeInTheDocument ( ) ;
123+ expect ( screen . getByRole ( "button" , { name : / r e t r y / i } ) ) . toBeInTheDocument ( ) ;
124+ } ) ;
125+
126+ it ( "keeps the action tray timestamp on one line" , ( ) => {
127+ const { container } = render (
128+ < MessageBubble
129+ message = { assistantMessage ( [ { type : "text" , text : "response" } ] ) }
130+ /> ,
131+ ) ;
132+
133+ const timestamp = container . querySelector (
134+ '[data-role="assistant-message"] [data-role="message-timestamp"]' ,
135+ ) ;
136+ expect ( timestamp ) . toHaveClass ( "whitespace-nowrap" ) ;
137+ expect ( timestamp ) . toHaveClass ( "shrink-0" ) ;
138+ } ) ;
139+
140+ it ( "anchors assistant and user actions on opposite sides of the timestamp" , ( ) => {
141+ const { container } = render (
142+ < >
143+ < MessageBubble
144+ message = { assistantMessage ( [ { type : "text" , text : "response" } ] ) }
145+ onRetryMessage = { vi . fn ( ) }
146+ />
147+ < MessageBubble message = { userMessage ( "draft" ) } onEditMessage = { vi . fn ( ) } />
148+ </ > ,
149+ ) ;
150+
151+ const assistantActions = container . querySelector (
152+ '[data-role="assistant-message"] [data-role="message-actions"]' ,
153+ ) ;
154+ const userActions = container . querySelector (
155+ '[data-role="user-message"] [data-role="message-actions"]' ,
156+ ) ;
157+
158+ expect (
159+ Array . from ( assistantActions ?. firstElementChild ?. children ?? [ ] ) . map (
160+ ( element ) => element . tagName ,
161+ ) ,
162+ ) . toEqual ( [ "BUTTON" , "BUTTON" , "SPAN" ] ) ;
163+ expect (
164+ Array . from ( userActions ?. firstElementChild ?. children ?? [ ] ) . map (
165+ ( element ) => element . tagName ,
166+ ) ,
167+ ) . toEqual ( [ "SPAN" , "BUTTON" , "BUTTON" ] ) ;
168+ } ) ;
169+
170+ it ( "keeps copy confirmation visible until it resets" , async ( ) => {
171+ vi . useFakeTimers ( ) ;
172+ const { container } = render (
173+ < MessageBubble
174+ message = { assistantMessage ( [ { type : "text" , text : "response" } ] ) }
175+ /> ,
176+ ) ;
177+
178+ const actions = container . querySelector (
179+ '[data-role="assistant-message"] [data-role="message-actions"]' ,
180+ ) ;
181+ expect ( actions ) . toHaveAttribute ( "data-copy-confirmed" , "false" ) ;
182+ const copyButton = screen . getByRole ( "button" , { name : / c o p y / i } ) ;
183+ expect ( copyButton ) . not . toHaveClass ( "bg-accent" ) ;
184+
185+ await act ( async ( ) => {
186+ fireEvent . click ( copyButton ) ;
187+ await Promise . resolve ( ) ;
188+ } ) ;
189+
190+ expect ( mockWriteText ) . toHaveBeenCalledWith ( "response" ) ;
191+ expect ( actions ) . toHaveAttribute ( "data-copy-confirmed" , "true" ) ;
192+ expect ( copyButton ) . toHaveClass ( "bg-accent" ) ;
193+
194+ await act ( async ( ) => {
195+ vi . advanceTimersByTime ( 1999 ) ;
196+ } ) ;
197+ expect ( actions ) . toHaveAttribute ( "data-copy-confirmed" , "true" ) ;
198+ expect ( copyButton ) . toHaveClass ( "bg-accent" ) ;
199+
200+ await act ( async ( ) => {
201+ vi . advanceTimersByTime ( 1 ) ;
202+ } ) ;
203+ expect ( actions ) . toHaveAttribute ( "data-copy-confirmed" , "false" ) ;
204+ expect ( copyButton ) . not . toHaveClass ( "bg-accent" ) ;
89205 } ) ;
90206
91207 it ( "renders tool request content as ToolCallCard" , ( ) => {
0 commit comments