@@ -522,3 +522,114 @@ test("thread panel width uses session storage and reset handle", async ({
522522 } )
523523 . toBe ( defaultWidthPx ) ;
524524} ) ;
525+
526+ test ( "composer is focused after selecting a channel" , async ( { page } ) => {
527+ await page . goto ( "/" ) ;
528+ await page . getByTestId ( "channel-general" ) . click ( ) ;
529+ await expect ( page . getByTestId ( "chat-title" ) ) . toHaveText ( "general" ) ;
530+
531+ // Without clicking the input, typing should land in the composer.
532+ const input = page . getByTestId ( "message-input" ) ;
533+ await expect ( input ) . toBeFocused ( ) ;
534+
535+ await page . keyboard . type ( "autofocus-on-channel-select" ) ;
536+ await expect ( input ) . toHaveText ( "autofocus-on-channel-select" ) ;
537+ } ) ;
538+
539+ test ( "composer is focused after switching to a different channel" , async ( {
540+ page,
541+ } ) => {
542+ await page . goto ( "/" ) ;
543+ await page . getByTestId ( "channel-general" ) . click ( ) ;
544+ await expect ( page . getByTestId ( "chat-title" ) ) . toHaveText ( "general" ) ;
545+
546+ await page . getByTestId ( "channel-random" ) . click ( ) ;
547+ await expect ( page . getByTestId ( "chat-title" ) ) . toHaveText ( "random" ) ;
548+
549+ const input = page . getByTestId ( "message-input" ) ;
550+ await expect ( input ) . toBeFocused ( ) ;
551+ } ) ;
552+
553+ test ( "thread composer is focused after clicking the reply icon" , async ( {
554+ page,
555+ } ) => {
556+ await page . goto ( "/" ) ;
557+ await page . getByTestId ( "channel-general" ) . click ( ) ;
558+ await expect ( page . getByTestId ( "chat-title" ) ) . toHaveText ( "general" ) ;
559+
560+ // Seed a message to reply to.
561+ const seed = `Thread autofocus seed ${ Date . now ( ) } ` ;
562+ const mainInput = page . getByTestId ( "message-input" ) ;
563+ await mainInput . fill ( seed ) ;
564+ await page . getByTestId ( "send-message" ) . click ( ) ;
565+ await expect ( page . getByTestId ( "message-timeline" ) ) . toContainText ( seed ) ;
566+
567+ const rootMessage = page
568+ . getByTestId ( "message-timeline" )
569+ . getByTestId ( "message-row" )
570+ . last ( ) ;
571+ await rootMessage . hover ( ) ;
572+ await rootMessage . getByRole ( "button" , { name : "Reply" } ) . click ( ) ;
573+
574+ const threadPanel = page . getByTestId ( "message-thread-panel" ) ;
575+ await expect ( threadPanel ) . toBeVisible ( ) ;
576+
577+ const threadInput = threadPanel . getByTestId ( "message-input" ) ;
578+ await expect ( threadInput ) . toBeFocused ( ) ;
579+
580+ await page . keyboard . type ( "typed-into-thread" ) ;
581+ await expect ( threadInput ) . toHaveText ( "typed-into-thread" ) ;
582+ } ) ;
583+
584+ test ( "thread composer keeps focus after sending a thread reply" , async ( {
585+ page,
586+ } ) => {
587+ await page . goto ( "/" ) ;
588+ await page . getByTestId ( "channel-general" ) . click ( ) ;
589+ await expect ( page . getByTestId ( "chat-title" ) ) . toHaveText ( "general" ) ;
590+
591+ // Seed a root message we can open a thread on. At this point only one
592+ // composer is mounted, so plain getByTestId is unambiguous.
593+ const seed = `Thread focus-after-send seed ${ Date . now ( ) } ` ;
594+ await page . getByTestId ( "message-input" ) . fill ( seed ) ;
595+ await page . getByTestId ( "send-message" ) . click ( ) ;
596+ await expect ( page . getByTestId ( "message-timeline" ) ) . toContainText ( seed ) ;
597+
598+ const rootMessage = page
599+ . getByTestId ( "message-timeline" )
600+ . getByTestId ( "message-row" )
601+ . last ( ) ;
602+ await rootMessage . hover ( ) ;
603+ await rootMessage . getByRole ( "button" , { name : "Reply" } ) . click ( ) ;
604+
605+ const threadPanel = page . getByTestId ( "message-thread-panel" ) ;
606+ await expect ( threadPanel ) . toBeVisible ( ) ;
607+
608+ const threadInput = threadPanel . getByTestId ( "message-input" ) ;
609+ await expect ( threadInput ) . toBeFocused ( ) ;
610+
611+ // Send a thread reply. After the send, `isSending` flips and back to false
612+ // in both the main and thread composers; the thread input must keep focus.
613+ const reply = `Thread reply ${ Date . now ( ) } ` ;
614+ await page . keyboard . type ( reply ) ;
615+ await expect ( threadInput ) . toHaveText ( reply ) ;
616+ await page . keyboard . press ( "Enter" ) ;
617+
618+ // Wait for the send to settle.
619+ await expect ( threadPanel ) . toContainText ( reply ) ;
620+
621+ // The thread input should still be focused — not the main composer.
622+ // Both composers expose the same `message-input` data-testid, so we
623+ // verify directly that `document.activeElement` lives inside the thread
624+ // panel rather than the main pane.
625+ const focusInThreadPanel = await page . evaluate ( ( ) => {
626+ const panel = document . querySelector < HTMLElement > (
627+ '[data-testid="message-thread-panel"]' ,
628+ ) ;
629+ const active = document . activeElement as HTMLElement | null ;
630+ return Boolean ( panel && active && panel . contains ( active ) ) ;
631+ } ) ;
632+ expect ( focusInThreadPanel ) . toBe ( true ) ;
633+
634+ await expect ( threadInput ) . toBeFocused ( ) ;
635+ } ) ;
0 commit comments