@@ -24,7 +24,7 @@ import { afterEach, test } from 'node:test';
2424import { act , StrictMode } from 'react' ;
2525import { createRoot } from 'react-dom/client' ;
2626import { parseHTML } from 'linkedom' ;
27- import { LocalizedChatMessage , TurnView } from '../chat-turn.js' ;
27+ import { LocalizedChatMessage , TurnRunningStatus , TurnView } from '../chat-turn.js' ;
2828import { LocaleProvider } from '../locale-context.js' ;
2929import type { TurnTimelineItem , TurnViewModel } from '../materialize.js' ;
3030
@@ -453,6 +453,47 @@ test('keeps Astryx auto formatting live for user-message timestamps', async (con
453453 assert . match ( timestamp . textContent ?? '' , / 3 h o u r s a g o / ) ;
454454} ) ;
455455
456+ test ( 'rotates working phrases on the elapsed clock without announcing each phrase' , async ( context ) => {
457+ const now = Date . UTC ( 2026 , 8 , 14 , 12 ) ;
458+ context . mock . timers . enable ( { apis : [ 'Date' , 'setInterval' ] , now } ) ;
459+ const { container, root } = domRoot ( ) ;
460+ await act ( ( ) => root . render ( < LocaleProvider locale = "en" > < TurnRunningStatus startedAt = { now } /> </ LocaleProvider > ) ) ;
461+ const status = container . querySelector ( '[role="status"]' ) ! ;
462+ assert . match ( status . textContent , / P o n d e r i n g / ) ;
463+ assert . equal ( status . getAttribute ( 'aria-label' ) , 'Working…' ) ;
464+ await act ( ( ) => context . mock . timers . tick ( 20_000 ) ) ;
465+ assert . match ( status . textContent , / T i n k e r i n g / ) ;
466+ assert . match ( status . textContent , / 2 0 s / ) ;
467+ assert . equal ( status . getAttribute ( 'aria-label' ) , 'Working…' ) ;
468+ // Concrete activity takes precedence over the playful phrase.
469+ await act ( ( ) => root . render ( < LocaleProvider locale = "en" > < TurnRunningStatus startedAt = { now } activityLabel = "Clicking Save" /> </ LocaleProvider > ) ) ;
470+ assert . match ( status . textContent , / C l i c k i n g S a v e / ) ;
471+ assert . doesNotMatch ( status . textContent , / T i n k e r i n g / ) ;
472+ } ) ;
473+
474+ test ( 'keeps elapsed time while system motion preference changes the working phrase' , async ( context ) => {
475+ const now = Date . UTC ( 2026 , 8 , 14 , 12 ) ;
476+ context . mock . timers . enable ( { apis : [ 'Date' , 'setInterval' ] , now } ) ;
477+ const { container, root } = domRoot ( ) ;
478+ let reduced = true ;
479+ const listeners = new Set < ( ) => void > ( ) ;
480+ Object . assign ( globalThis , { matchMedia : ( ) => ( {
481+ get matches ( ) { return reduced ; } ,
482+ addEventListener ( _type : string , listener : ( ) => void ) { listeners . add ( listener ) ; } ,
483+ removeEventListener ( _type : string , listener : ( ) => void ) { listeners . delete ( listener ) ; } ,
484+ } ) } ) ;
485+ await act ( ( ) => root . render ( < LocaleProvider locale = "en" > < TurnRunningStatus startedAt = { now } /> </ LocaleProvider > ) ) ;
486+ await act ( ( ) => context . mock . timers . tick ( 20_000 ) ) ;
487+ assert . equal ( container . querySelector ( '.maka-turn-status-label' ) ?. textContent , 'Pondering…' ) ;
488+ assert . equal ( container . querySelector ( '.maka-turn-elapsed' ) ?. textContent , '20s' ) ;
489+ await act ( ( ) => { reduced = false ; listeners . forEach ( ( listener ) => listener ( ) ) ; } ) ;
490+ assert . equal ( container . querySelector ( '.maka-turn-status-label' ) ?. textContent , 'Tinkering…' ) ;
491+ await act ( ( ) => { reduced = true ; listeners . forEach ( ( listener ) => listener ( ) ) ; } ) ;
492+ await act ( ( ) => context . mock . timers . tick ( 20_000 ) ) ;
493+ assert . equal ( container . querySelector ( '.maka-turn-status-label' ) ?. textContent , 'Pondering…' ) ;
494+ assert . equal ( container . querySelector ( '.maka-turn-elapsed' ) ?. textContent , '40s' ) ;
495+ } ) ;
496+
456497/**
457498 * The live handoff announces itself exactly once, when the answer enters its
458499 * settled phase. A bubble replayed from history mounts already past the
@@ -641,18 +682,49 @@ test('automatically folds a running process on completion without remounting the
641682 assert . equal ( container . querySelectorAll ( '.maka-chat-message-bubble-assistant' ) [ 1 ] ?. isSameNode ( answer ! ) , true ) ;
642683} ) ;
643684
644- test ( 'respects a manual expansion through appended events and completion' , async ( ) => {
685+ test ( 'copy uses the visible final reply after completion and disclosure toggles' , async ( ) => {
686+ const { container, root } = domRoot ( ) ;
687+ const copied : string [ ] = [ ] ;
688+ Object . defineProperty ( navigator , 'clipboard' , { configurable : true , value : {
689+ writeText : async ( text : string ) => { copied . push ( text ) ; } ,
690+ } } ) ;
691+ const timeline = [ PROCESS_TEXT , COMPLETED_TOOL , { ...ANSWER , live : false } ] ;
692+ await renderTurn ( root , turnWith ( timeline ) ) ;
693+ await act ( ( ) => root . render ( < LocaleProvider locale = "en" > < TurnView
694+ turn = { { ...turnWith ( timeline ) , status : 'completed' } }
695+ footerActions = { [ { id : 'copy' , label : 'Copy' , enabled : true } ] }
696+ /> </ LocaleProvider > ) ) ;
697+ const process = container . querySelector ( 'details.maka-processing-sequence' ) ;
698+ const summary = process ?. querySelector ( 'summary' ) ;
699+ const copy = container . querySelector ( '[data-action="copy"]' ) ;
700+ assert . ok ( process && summary && copy ) ;
701+ assert . equal ( process . hasAttribute ( 'open' ) , false ) ;
702+ assert . doesNotMatch ( process . textContent ?? '' , / t h e a n s w e r / ) ;
703+ for ( let index = 0 ; index < 3 ; index += 1 ) {
704+ await act ( async ( ) => { copy . dispatchEvent ( new window . Event ( 'click' , { bubbles : true } ) ) ; } ) ;
705+ await act ( ( ) => { summary . dispatchEvent ( new window . Event ( 'click' , { bubbles : true , cancelable : true } ) ) ; } ) ;
706+ }
707+ assert . deepEqual ( copied , [ ANSWER . text , ANSWER . text , ANSWER . text ] ) ;
708+ } ) ;
709+
710+ test ( 'keeps running work expanded and allows manual disclosure after settlement' , async ( ) => {
645711 const { container, root } = domRoot ( ) ;
646712 await renderTurn ( root , turnWith ( [ PROCESS_TEXT , COMPLETED_TOOL ] ) ) ;
647713 const process = container . querySelector ( 'details.maka-processing-sequence' ) ;
648714 const summary = process ?. querySelector ( 'summary' ) ;
649715 assert . ok ( process && summary ) ;
650716 const click = ( ) => act ( ( ) => { summary . dispatchEvent ( new window . Event ( 'click' , { bubbles : true , cancelable : true } ) ) ; } ) ;
651- await click ( ) ; // explicitly hide the running process
717+ assert . equal ( summary . getAttribute ( 'aria-disabled' ) , 'true' ) ;
718+ assert . equal ( summary . getAttribute ( 'tabindex' ) , '-1' ) ;
719+ await click ( ) ; // pointer activation cannot hide live work
652720 await renderTurn ( root , turnWith ( [ PROCESS_TEXT , COMPLETED_TOOL , ANSWER ] ) ) ;
653- assert . equal ( process . hasAttribute ( 'open' ) , false ) ;
654- await click ( ) ; // explicitly reopen it
721+ assert . equal ( process . hasAttribute ( 'open' ) , true ) ;
655722 await renderTurn ( root , { ...turnWith ( [ PROCESS_TEXT , COMPLETED_TOOL , { ...ANSWER , live : false } ] ) , status : 'completed' } ) ;
723+ assert . equal ( process . hasAttribute ( 'open' ) , false ) ;
724+ assert . equal ( summary . hasAttribute ( 'aria-disabled' ) , false ) ;
725+ assert . equal ( summary . getAttribute ( 'tabindex' ) , '0' ) ;
726+ await click ( ) ;
727+ await renderTurn ( root , { ...turnWith ( [ PROCESS_TEXT , COMPLETED_TOOL , { ...ANSWER , live : false } ] ) , status : 'completed' , durationMs : 2000 } ) ;
656728 assert . equal ( process . hasAttribute ( 'open' ) , true ) ;
657729} ) ;
658730
@@ -663,7 +735,7 @@ test('a newly failed tool reveals the process while turn recovery stays outside'
663735 const summary = process ?. querySelector ( 'summary' ) ;
664736 assert . ok ( process && summary ) ;
665737 await act ( ( ) => { summary . dispatchEvent ( new window . Event ( 'click' , { bubbles : true , cancelable : true } ) ) ; } ) ;
666- assert . equal ( process . hasAttribute ( 'open' ) , false ) ;
738+ assert . equal ( process . hasAttribute ( 'open' ) , true ) ;
667739 await act ( ( ) => root . render ( < LocaleProvider locale = "en" > < TurnView
668740 turn = { { ...turnWith ( [ PROCESS_TEXT , { kind : 'tools' , items : [ { toolUseId : 'tool-1' , toolName : 'read' , args : { } , status : 'errored' } ] } ] ) , status : 'failed' } }
669741 failedReasonLabel = "Read failed"
@@ -673,6 +745,9 @@ test('a newly failed tool reveals the process while turn recovery stays outside'
673745 assert . match ( summary . textContent ?? '' , / N e e d s a t t e n t i o n / ) ;
674746 assert . doesNotMatch ( process . textContent ?? '' , / C o n t i n u e t h i s t u r n / ) ;
675747 assert . match ( container . textContent ?? '' , / C o n t i n u e t h i s t u r n / ) ;
748+ await act ( ( ) => { summary . dispatchEvent ( new window . Event ( 'click' , { bubbles : true , cancelable : true } ) ) ; } ) ;
749+ assert . equal ( process . hasAttribute ( 'open' ) , false ) ;
750+ assert . match ( container . textContent ?? '' , / C o n t i n u e t h i s t u r n / ) ;
676751} ) ;
677752
678753test ( 'uses a generic process label when no duration is recorded, and localizes known duration' , async ( ) => {
0 commit comments