1- import React , { useEffect , useRef , useState } from 'react' ;
1+ import React , { useEffect , useLayoutEffect , useRef , useState } from 'react' ;
22
33import type { ChatMeta } from '../../types' ;
44
@@ -31,6 +31,7 @@ export type AssistantMessage = {
3131 text : string ;
3232 streaming : boolean ;
3333 errored ?: boolean ;
34+ cancelled ?: boolean ;
3435} ;
3536
3637export type ToolMessage = {
@@ -170,6 +171,9 @@ export function ProjectScreen( {
170171 const editInputRef = useRef < HTMLInputElement | null > ( null ) ;
171172 const [ addMenuOpen , setAddMenuOpen ] = useState ( false ) ;
172173 const addMenuRef = useRef < HTMLDivElement | null > ( null ) ;
174+ const transcriptRef = useRef < HTMLElement | null > ( null ) ;
175+ const composerInputRef = useRef < HTMLTextAreaElement | null > ( null ) ;
176+ const lastUserIdRef = useRef < string | null > ( null ) ;
173177
174178 useEffect ( ( ) => {
175179 if ( editingChatId ) {
@@ -178,6 +182,56 @@ export function ProjectScreen( {
178182 }
179183 } , [ editingChatId ] ) ;
180184
185+ // Resync the composer height whenever the input value changes — the
186+ // textarea grows with content, capped by CSS max-height (50vh).
187+ useLayoutEffect ( ( ) => {
188+ const el = composerInputRef . current ;
189+ if ( ! el ) {
190+ return ;
191+ }
192+ el . style . height = 'auto' ;
193+ el . style . height = `${ el . scrollHeight } px` ;
194+ } , [ input ] ) ;
195+
196+ // Don't scroll on chat switch: reset the tracker so the next "new user
197+ // message" detection fires only when the user actually sends.
198+ useEffect ( ( ) => {
199+ lastUserIdRef . current = null ;
200+ } , [ activeChatId ] ) ;
201+
202+ // When a new user message appears at the tail of the transcript, scroll
203+ // the transcript to the bottom so the just-sent message and the
204+ // (about-to-stream) assistant bubble are visible.
205+ useEffect ( ( ) => {
206+ let lastUser : UserMessage | null = null ;
207+ for ( let i = messages . length - 1 ; i >= 0 ; i -- ) {
208+ const m = messages [ i ] ;
209+ if ( m . kind === 'user' ) {
210+ lastUser = m ;
211+ break ;
212+ }
213+ }
214+ if ( ! lastUser ) {
215+ return ;
216+ }
217+ if ( lastUser . id === lastUserIdRef . current ) {
218+ return ;
219+ }
220+ const isFirstSee = lastUserIdRef . current === null ;
221+ lastUserIdRef . current = lastUser . id ;
222+ if ( isFirstSee ) {
223+ return ;
224+ }
225+ const transcript = transcriptRef . current ;
226+ if ( ! transcript ) {
227+ return ;
228+ }
229+ transcript . scrollTo ( {
230+ top : transcript . scrollHeight ,
231+ behavior : 'smooth' ,
232+ } ) ;
233+ } , [ messages ] ) ;
234+
181235 const startEditingTab = ( chatId : string ) : void => {
182236 const current = chats . find ( ( c ) => c . id === chatId ) ;
183237 setEditingChatId ( chatId ) ;
@@ -632,7 +686,11 @@ export function ProjectScreen( {
632686 </ div >
633687 ) }
634688
635- < main className = "transcript" data-testid = "transcript" >
689+ < main
690+ className = "transcript"
691+ data-testid = "transcript"
692+ ref = { transcriptRef }
693+ >
636694 { groupMessages ( messages ) . map ( ( item ) => {
637695 if ( item . kind === 'user' ) {
638696 return (
@@ -648,20 +706,65 @@ export function ProjectScreen( {
648706 ) ;
649707 }
650708 if ( item . kind === 'assistant' ) {
709+ const isWorking =
710+ item . streaming && item . text . length === 0 ;
711+ const isCancelled =
712+ ! item . streaming && ! ! item . cancelled ;
713+ // Drop bubbles that finished with no text and
714+ // weren't cancelled (e.g. tool-only turns):
715+ // they used to render as silent empty bubbles.
716+ if (
717+ ! item . streaming &&
718+ ! isCancelled &&
719+ item . text . length === 0
720+ ) {
721+ return null ;
722+ }
651723 return (
652724 < div
653725 key = { item . id }
654726 className = { `bubble bubble-assistant${
655727 item . errored ? ' bubble-error' : ''
728+ } ${
729+ isCancelled
730+ ? ' bubble-cancelled'
731+ : ''
656732 } ` }
657733 data-testid = "bubble-assistant"
658734 data-streaming = {
659735 item . streaming ? 'true' : 'false'
660736 }
737+ data-cancelled = {
738+ isCancelled ? 'true' : 'false'
739+ }
661740 >
662- < div className = "bubble-text" >
663- { item . text }
664- </ div >
741+ { isWorking ? (
742+ < div
743+ className = "bubble-thinking"
744+ data-testid = "bubble-thinking"
745+ aria-label = "Assistant is working"
746+ >
747+ < span />
748+ < span />
749+ < span />
750+ </ div >
751+ ) : (
752+ < >
753+ { item . text . length > 0 && (
754+ < div className = "bubble-text" >
755+ { item . text }
756+ </ div >
757+ ) }
758+ { isCancelled && (
759+ < div
760+ className = "bubble-stopped"
761+ data-testid = "bubble-stopped"
762+ >
763+ Stopped
764+ </ div >
765+ ) }
766+ </ >
767+ ) }
665768 </ div >
666769 ) ;
667770 }
@@ -774,14 +877,15 @@ export function ProjectScreen( {
774877 < div className = "composer" data-testid = "composer" >
775878 < div className = "composer-field" >
776879 < textarea
880+ ref = { composerInputRef }
777881 className = "composer-input"
778882 data-testid = "chat-input"
779883 placeholder = {
780884 activeProjectId
781885 ? 'Message Studio Write… (Enter to send, Shift+Enter for newline)'
782886 : 'Link a project to start chatting'
783887 }
784- rows = { 3 }
888+ rows = { 1 }
785889 value = { input }
786890 onChange = { ( e ) =>
787891 onInputChange ( e . target . value )
0 commit comments