11import { expect , story } from "../../stories/playwright/story"
22
3- story . describe ( "message timestamp " , ( ) => {
3+ story . describe ( "message actions menu " , ( ) => {
44 story . use ( { timezoneId : "UTC" } )
55
6- story ( "shows the full creation date, time, and time zone on hover and focus " , async ( { mount, page } ) => {
6+ story ( "shows the creation time and disables actions the message cannot take " , async ( { mount, page } ) => {
77 const timeline = await mount ( "current-prompt-message--pending" )
8- const timestamp = timeline . locator ( '[data-slot="user-message-meta-tail"]' )
9- await expect ( timestamp . locator ( "time" ) ) . toHaveText ( "Jan 1, 2025" )
10- await timeline . getByLabel ( "Delivery state" ) . selectOption ( "delivered" )
118 const message = timeline . locator ( '[data-component="user-message"]' )
9+ const more = timeline . getByRole ( "button" , { name : "More actions" , exact : true } )
10+ const fork = page . getByRole ( "menuitem" , { name : "Fork from here" , exact : true } )
11+ const copy = page . getByRole ( "menuitem" , { name : "Copy message" , exact : true } )
12+
13+ // The meta line keeps agent and model; the time moved into the menu.
14+ await expect ( timeline . locator ( '[data-slot="user-message-meta"] time' ) ) . toHaveCount ( 0 )
15+
16+ await timeline . getByLabel ( "Delivery state" ) . selectOption ( "delivered" )
1217 await message . hover ( )
13- await expect ( timestamp ) . toHaveCSS ( "opacity" , "1" )
14- await page . clock . install ( )
15- await page . clock . pauseAt ( new Date ( Date . now ( ) + 1000 ) )
16- await timestamp . locator ( "time" ) . hover ( )
17- const tooltip = page . getByRole ( "tooltip" )
18- await page . clock . runFor ( 799 )
19- await expect ( tooltip ) . toBeHidden ( )
20- await page . clock . runFor ( 1 )
21- await expect ( tooltip ) . toHaveText ( "Wednesday, January 1, 2025 at 12:00:00 AM UTC" )
22- await page . clock . resume ( )
18+ await expect ( timeline . getByRole ( "button" , { name : "Rewind to here" , exact : true } ) ) . toBeVisible ( )
19+ await more . click ( )
20+ const menu = page . getByRole ( "menu" )
21+ await expect ( menu . locator ( "time" ) ) . toHaveText ( "Jan 1, 2025, 12:00 AM" )
22+ await expect ( fork ) . toBeEnabled ( )
23+ await expect ( copy ) . toBeEnabled ( )
24+ // An open menu keeps the footer in view after the pointer leaves the message.
2325 await page . mouse . move ( 0 , 0 )
24- await expect ( tooltip ) . toBeHidden ( )
25- await expect ( timestamp ) . toHaveCSS ( "opacity" , "0" )
26- await timestamp . locator ( '[data-component="tooltip-v2-trigger"]' ) . focus ( )
27- await expect ( tooltip ) . toBeVisible ( )
26+ await expect ( timeline . locator ( '[data-slot="user-message-copy-wrapper"]' ) ) . toHaveCSS ( "opacity" , "1" )
27+ await page . keyboard . press ( "Escape" )
28+ await expect ( menu ) . toBeHidden ( )
2829
30+ // A pending steer cannot fork, and a message without text has nothing to copy.
31+ await timeline . getByLabel ( "Delivery state" ) . selectOption ( "steering" )
32+ await timeline . getByLabel ( "Prompt text" , { exact : true } ) . uncheck ( )
33+ await more . click ( )
34+ await expect ( fork ) . toHaveAttribute ( "aria-disabled" , "true" )
35+ await expect ( copy ) . toHaveAttribute ( "aria-disabled" , "true" )
36+ await page . keyboard . press ( "Escape" )
37+
38+ await timeline . getByLabel ( "Prompt text" , { exact : true } ) . check ( )
39+ await timeline . getByLabel ( "Delivery state" ) . selectOption ( "delivered" )
2940 await page . evaluate ( ( ) => document . fonts . ready . then ( ( ) => undefined ) )
3041
3142 for ( const width of [ 1000 , 390 ] ) {
3243 await page . setViewportSize ( { width, height : 844 } )
3344 const bubble = await timeline . locator ( '[data-slot="user-message-text"]' ) . boundingBox ( )
34- const time = await timestamp . boundingBox ( )
45+ const trigger = await more . boundingBox ( )
3546 const footer = await timeline . locator ( '[data-slot="user-message-copy-wrapper"]' ) . boundingBox ( )
36- const message = await timeline . locator ( '[data-component="user- message"]' ) . boundingBox ( )
47+ const box = await message . boundingBox ( )
3748
38- if ( ! bubble || ! time || ! footer || ! message ) throw new Error ( "Message geometry is unavailable" )
39- expect ( Math . abs ( bubble . x + bubble . width - message . x - message . width ) ) . toBeLessThan ( 1 )
49+ if ( ! bubble || ! trigger || ! footer || ! box ) throw new Error ( "Message geometry is unavailable" )
50+ expect ( Math . abs ( bubble . x + bubble . width - box . x - box . width ) ) . toBeLessThan ( 1 )
4051 expect ( footer . y ) . toBeGreaterThanOrEqual ( bubble . y + bubble . height )
4152 expect ( footer . x + footer . width ) . toBeLessThanOrEqual ( bubble . x + bubble . width + 1 )
42- expect ( time . x ) . toBeGreaterThanOrEqual ( footer . x )
43- expect ( time . x + time . width ) . toBeLessThanOrEqual ( footer . x + footer . width )
44- expect ( Math . abs ( time . y + time . height / 2 - footer . y - footer . height / 2 ) ) . toBeLessThan ( 1 )
53+ expect ( Math . abs ( trigger . x + trigger . width - footer . x - footer . width ) ) . toBeLessThan ( 1 )
54+ expect ( Math . abs ( trigger . y + trigger . height / 2 - footer . y - footer . height / 2 ) ) . toBeLessThan ( 1 )
4555 }
4656 } )
4757} )
@@ -50,13 +60,11 @@ story("drops a pending footer at once on delivery with and without hover", async
5060 const timeline = await mount ( "current-prompt-message--pending" )
5161 const message = timeline . locator ( '[data-component="user-message"]' )
5262 const footer = timeline . locator ( '[data-slot="user-message-copy-wrapper"]' )
53- const timestamp = timeline . locator ( '[data-slot="user-message-meta-tail"]' )
5463 const pending = timeline . locator ( '[data-slot="user-message-transition"]:has([data-slot="user-message-pending"])' )
5564
5665 await page . mouse . move ( 0 , 0 )
5766 await expect ( footer ) . toHaveCSS ( "height" , "20px" )
58- await expect ( footer ) . toHaveCSS ( "margin-top" , "4px" )
59- await expect ( timestamp ) . toHaveCSS ( "opacity" , "0" )
67+ await expect ( footer ) . toHaveCSS ( "margin-top" , "8px" )
6068
6169 for ( const hover of [ true , false ] ) {
6270 await timeline . getByLabel ( "Delivery state" ) . selectOption ( "steering" )
@@ -69,8 +77,6 @@ story("drops a pending footer at once on delivery with and without hover", async
6977 if ( hover ) await message . hover ( )
7078
7179 if ( ! hover ) await page . mouse . move ( 0 , 0 )
72- // Pending stands in for the unsent time, so the actions follow the labels without a gap held for it.
73- await expect ( timestamp ) . toBeHidden ( )
7480 const label = await timeline . locator ( '[data-slot="user-message-meta-item"]' ) . last ( ) . boundingBox ( )
7581 const actions = await timeline . locator ( '[data-slot="user-message-actions"]' ) . boundingBox ( )
7682
@@ -99,7 +105,6 @@ story("drops a pending footer at once on delivery with and without hover", async
99105
100106 await expect ( pending ) . toBeHidden ( )
101107 await expect ( footer ) . toHaveCSS ( "opacity" , hover ? "1" : "0" )
102- await expect ( timestamp ) . toHaveCSS ( "opacity" , hover ? "1" : "0" )
103108 }
104109
105110 await page . emulateMedia ( { reducedMotion : "reduce" } )
@@ -145,44 +150,23 @@ story("marks pending metadata without badging delivered, starting, or queued pro
145150 "Claude Sonnet 4" ,
146151 "Balanced" ,
147152 ] )
148- await expect ( timeline . locator ( '[data-slot="user-message-meta-item"][data-variant]' ) ) . toHaveCSS ( "opacity" , "0.66" )
149153 const separator = timeline . locator ( '[data-slot="user-message-meta-label"] [data-slot="user-message-meta-separator"]' )
150154 await expect ( separator ) . toHaveText ( "\u00B7" )
151- await expect ( separator ) . toHaveCSS ( "opacity" , "0.66" )
152155 await expect ( timeline . locator ( '[data-slot="user-message-meta-label"]' ) ) . toHaveCSS ( "gap" , "4px" )
153156
154157 await timeline . getByLabel ( "Delivery state" ) . selectOption ( "steering" )
155158 await timeline . getByLabel ( "Voice message" , { exact : true } ) . check ( )
156159 const voice = timeline . getByRole ( "img" , { name : "Voice" , exact : true } )
157160 await expect ( voice ) . toBeVisible ( )
158161 await expect ( indicator ) . toBeVisible ( )
159- await expect ( indicator ) . toHaveAttribute ( "aria-hidden" , "true" )
162+ await expect ( indicator . locator ( "svg" ) ) . toHaveCount ( 0 )
160163
161164 await expect ( voice . locator ( "use" ) ) . toHaveAttribute ( "href" , "#opencode-v2-icon-outline-wave-audio" )
162- await expect ( indicator . locator ( "use" ) ) . toHaveAttribute ( "href" , "#opencode-v2-icon-outline-steer" )
163-
164- const icon = await indicator . locator ( "svg" ) . boundingBox ( )
165- const label = await timeline . locator ( '[data-slot="user-message-meta"]' ) . boundingBox ( )
166-
167- if ( ! icon || ! label ) throw new Error ( "Pending metadata geometry is unavailable" )
168- expect ( Math . abs ( icon . y + icon . height / 2 - label . y - label . height / 2 ) ) . toBeLessThan ( 0.5 )
169-
170165 await expect ( voice ) . toHaveCSS ( "width" , "20px" )
171166 await expect ( voice ) . toHaveCSS ( "height" , "20px" )
172167 await expect ( voice . locator ( '[data-slot="icon-svg"]' ) ) . toHaveCSS ( "width" , "14px" )
173168 await expect ( voice . locator ( '[data-slot="icon-svg"]' ) ) . toHaveCSS ( "height" , "14px" )
174169
175- const faint = await indicator . evaluate ( ( element ) => {
176- const probe = document . createElement ( "span" )
177- probe . style . color = "var(--v2-icon-icon-faint)"
178- element . append ( probe )
179- const color = getComputedStyle ( probe ) . color
180- probe . remove ( )
181-
182- return color
183- } )
184-
185- await expect ( indicator ) . toHaveCSS ( "color" , faint )
186170 await expect ( timeline . locator ( '[data-slot="user-message-meta"]' ) ) . toHaveText ( / ^ P e n d i n g / )
187171 await timeline . getByLabel ( "Grouped prompts" , { exact : true } ) . check ( )
188172 await expect ( timeline . locator ( '[data-run-folded] [data-slot="user-message-copy-wrapper"]' ) ) . toBeHidden ( )
0 commit comments