2
2
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
3
3
* SPDX-License-Identifier: Apache-2.0
4
4
*/
5
- import { Box , Button , Flex , HStack , Spinner , VStack } from '@chakra-ui/react' ;
5
+ import {
6
+ Box ,
7
+ Button ,
8
+ Flex ,
9
+ HStack ,
10
+ Menu ,
11
+ MenuButton ,
12
+ MenuItem ,
13
+ MenuList ,
14
+ Spinner ,
15
+ useDisclosure ,
16
+ VStack ,
17
+ } from '@chakra-ui/react' ;
6
18
import { useToast } from '@chakra-ui/react' ;
19
+ import { RiRecordCircleLine } from 'react-icons/ri' ;
20
+ import { TbReport } from 'react-icons/tb' ;
7
21
import React , { forwardRef , useEffect , useMemo , useRef } from 'react' ;
8
22
import { FaPaperPlane , FaStop , FaTrash } from 'react-icons/fa' ;
9
- import { LuScreenShare } from 'react-icons/lu' ;
23
+ import { HiChevronDown } from 'react-icons/hi' ;
24
+ import { FaRegShareFromSquare } from 'react-icons/fa6' ;
10
25
import { IoPlay } from 'react-icons/io5' ;
11
26
import { useDispatch } from 'zutron' ;
12
27
@@ -20,6 +35,7 @@ import { uploadReport } from '@renderer/utils/share';
20
35
21
36
import reportHTMLUrl from '@resources/report.html?url' ;
22
37
import { isCallUserMessage } from '@renderer/utils/message' ;
38
+ import { useScreenRecord } from '@renderer/hooks/useScreenRecord' ;
23
39
24
40
const ChatInput = forwardRef ( ( _props , _ref ) => {
25
41
const {
@@ -36,6 +52,18 @@ const ChatInput = forwardRef((_props, _ref) => {
36
52
37
53
const toast = useToast ( ) ;
38
54
const { run } = useRunAgent ( ) ;
55
+ const {
56
+ isOpen : isShareOpen ,
57
+ onOpen : onShareOpen ,
58
+ onClose : onShareClose ,
59
+ } = useDisclosure ( ) ;
60
+ const {
61
+ canSaveRecording,
62
+ startRecording,
63
+ stopRecording,
64
+ saveRecording,
65
+ recordRefs,
66
+ } = useScreenRecord ( ) ;
39
67
40
68
const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
41
69
const running = status === 'running' ;
@@ -44,6 +72,9 @@ const ChatInput = forwardRef((_props, _ref) => {
44
72
45
73
const startRun = ( ) => {
46
74
run ( localInstructions , ( ) => {
75
+ startRecording ( ) . catch ( ( e ) => {
76
+ console . error ( 'start recording failed:' , e ) ;
77
+ } ) ;
47
78
setLocalInstructions ( '' ) ;
48
79
} ) ;
49
80
} ;
@@ -74,6 +105,12 @@ const ChatInput = forwardRef((_props, _ref) => {
74
105
}
75
106
} , [ ] ) ;
76
107
108
+ useEffect ( ( ) => {
109
+ if ( status === StatusEnum . INIT ) {
110
+ return ;
111
+ }
112
+ } , [ status ] ) ;
113
+
77
114
const isCallUser = useMemo ( ( ) => isCallUserMessage ( messages ) , [ messages ] ) ;
78
115
79
116
/**
@@ -83,6 +120,14 @@ const ChatInput = forwardRef((_props, _ref) => {
83
120
if ( status === StatusEnum . END && isCallUser && savedInstructions ) {
84
121
setLocalInstructions ( savedInstructions ) ;
85
122
}
123
+ // record screen when running
124
+ if ( status !== StatusEnum . INIT ) {
125
+ stopRecording ( ) ;
126
+ }
127
+
128
+ return ( ) => {
129
+ stopRecording ( ) ;
130
+ } ;
86
131
} , [ isCallUser , status ] ) ;
87
132
88
133
const lastHumanMessage =
@@ -96,7 +141,7 @@ const ChatInput = forwardRef((_props, _ref) => {
96
141
const shareTimeoutRef = React . useRef < NodeJS . Timeout > ( ) ;
97
142
const SHARE_TIMEOUT = 100000 ;
98
143
99
- const handleShare = async ( ) => {
144
+ const handleShare = async ( type : 'report' | 'video' ) => {
100
145
if ( isSharePending . current ) {
101
146
return ;
102
147
}
@@ -118,73 +163,77 @@ const ChatInput = forwardRef((_props, _ref) => {
118
163
} ) ;
119
164
} , SHARE_TIMEOUT ) ;
120
165
121
- const response = await fetch ( reportHTMLUrl ) ;
122
- const html = await response . text ( ) ;
123
-
124
- const userData = {
125
- ...restUserData ,
126
- status,
127
- conversations : messages ,
128
- } as ComputerUseUserData ;
129
-
130
- const htmlContent = reportHTMLContent ( html , [ userData ] ) ;
131
-
132
- let reportUrl : string | undefined ;
133
-
134
- if ( settings ?. reportStorageBaseUrl ) {
135
- try {
136
- const { url } = await uploadReport (
137
- htmlContent ,
138
- settings . reportStorageBaseUrl ,
139
- ) ;
140
- reportUrl = url ;
141
- await navigator . clipboard . writeText ( url ) ;
142
- toast ( {
143
- title : 'Report link copied to clipboard!' ,
144
- status : 'success' ,
145
- position : 'top' ,
146
- duration : 2000 ,
147
- isClosable : true ,
148
- variant : 'ui-tars-success' ,
149
- } ) ;
150
- } catch ( error ) {
151
- console . error ( 'Share failed:' , error ) ;
152
- toast ( {
153
- title : 'Failed to upload report' ,
154
- description :
155
- error instanceof Error ? error . message : JSON . stringify ( error ) ,
156
- status : 'error' ,
157
- position : 'top' ,
158
- duration : 3000 ,
159
- isClosable : true ,
166
+ if ( type === 'video' ) {
167
+ saveRecording ( ) ;
168
+ } else if ( type === 'report' ) {
169
+ const response = await fetch ( reportHTMLUrl ) ;
170
+ const html = await response . text ( ) ;
171
+
172
+ const userData = {
173
+ ...restUserData ,
174
+ status,
175
+ conversations : messages ,
176
+ } as ComputerUseUserData ;
177
+
178
+ const htmlContent = reportHTMLContent ( html , [ userData ] ) ;
179
+
180
+ let reportUrl : string | undefined ;
181
+
182
+ if ( settings ?. reportStorageBaseUrl ) {
183
+ try {
184
+ const { url } = await uploadReport (
185
+ htmlContent ,
186
+ settings . reportStorageBaseUrl ,
187
+ ) ;
188
+ reportUrl = url ;
189
+ await navigator . clipboard . writeText ( url ) ;
190
+ toast ( {
191
+ title : 'Report link copied to clipboard!' ,
192
+ status : 'success' ,
193
+ position : 'top' ,
194
+ duration : 2000 ,
195
+ isClosable : true ,
196
+ variant : 'ui-tars-success' ,
197
+ } ) ;
198
+ } catch ( error ) {
199
+ console . error ( 'Share failed:' , error ) ;
200
+ toast ( {
201
+ title : 'Failed to upload report' ,
202
+ description :
203
+ error instanceof Error ? error . message : JSON . stringify ( error ) ,
204
+ status : 'error' ,
205
+ position : 'top' ,
206
+ duration : 3000 ,
207
+ isClosable : true ,
208
+ } ) ;
209
+ }
210
+ }
211
+
212
+ // Send UTIO data through IPC
213
+ if ( settings ?. utioBaseUrl ) {
214
+ const lastScreenshot = messages
215
+ . filter ( ( m ) => m . screenshotBase64 )
216
+ . pop ( ) ?. screenshotBase64 ;
217
+
218
+ await window . electron . utio . shareReport ( {
219
+ type : 'shareReport' ,
220
+ instruction : lastHumanMessage ,
221
+ lastScreenshot,
222
+ report : reportUrl ,
160
223
} ) ;
161
224
}
162
- }
163
225
164
- // Send UTIO data through IPC
165
- if ( settings ?. utioBaseUrl ) {
166
- const lastScreenshot = messages
167
- . filter ( ( m ) => m . screenshotBase64 )
168
- . pop ( ) ?. screenshotBase64 ;
169
-
170
- await window . electron . utio . shareReport ( {
171
- type : 'shareReport' ,
172
- instruction : lastHumanMessage ,
173
- lastScreenshot,
174
- report : reportUrl ,
175
- } ) ;
226
+ // If shareEndpoint is not configured or the upload fails, fall back to downloading the file
227
+ const blob = new Blob ( [ htmlContent ] , { type : 'text/html' } ) ;
228
+ const url = window . URL . createObjectURL ( blob ) ;
229
+ const a = document . createElement ( 'a' ) ;
230
+ a . href = url ;
231
+ a . download = `report-${ Date . now ( ) } .html` ;
232
+ document . body . appendChild ( a ) ;
233
+ a . click ( ) ;
234
+ document . body . removeChild ( a ) ;
235
+ window . URL . revokeObjectURL ( url ) ;
176
236
}
177
-
178
- // If shareEndpoint is not configured or the upload fails, fall back to downloading the file
179
- const blob = new Blob ( [ htmlContent ] , { type : 'text/html' } ) ;
180
- const url = window . URL . createObjectURL ( blob ) ;
181
- const a = document . createElement ( 'a' ) ;
182
- a . href = url ;
183
- a . download = `report-${ Date . now ( ) } .html` ;
184
- document . body . appendChild ( a ) ;
185
- a . click ( ) ;
186
- document . body . removeChild ( a ) ;
187
- window . URL . revokeObjectURL ( url ) ;
188
237
} catch ( error ) {
189
238
console . error ( 'Share failed:' , error ) ;
190
239
toast ( {
@@ -270,17 +319,46 @@ const ChatInput = forwardRef((_props, _ref) => {
270
319
< HStack justify = "space-between" align = "center" w = "100%" >
271
320
< Box >
272
321
{ status !== StatusEnum . RUNNING && messages ?. length > 1 && (
273
- < Button
274
- variant = "tars-ghost"
275
- aria-label = "Share"
276
- onClick = { handleShare }
277
- isDisabled = { isSharing }
278
- >
279
- { isSharing ? < Spinner size = "sm" /> : < LuScreenShare /> }
280
- </ Button >
322
+ < HStack spacing = { 2 } >
323
+ < Menu isLazy isOpen = { isShareOpen } onClose = { onShareClose } >
324
+ < MenuButton
325
+ as = { Button }
326
+ onMouseOver = { onShareOpen }
327
+ variant = "tars-ghost"
328
+ aria-label = "Share options"
329
+ rightIcon = { < HiChevronDown /> }
330
+ >
331
+ { isSharing ? (
332
+ < Spinner size = "sm" />
333
+ ) : (
334
+ < FaRegShareFromSquare />
335
+ ) }
336
+ </ MenuButton >
337
+ < MenuList >
338
+ { canSaveRecording && (
339
+ < MenuItem onClick = { ( ) => handleShare ( 'video' ) } >
340
+ < HStack spacing = { 1 } >
341
+ < RiRecordCircleLine />
342
+ < span > Recording Video</ span >
343
+ </ HStack >
344
+ </ MenuItem >
345
+ ) }
346
+ < MenuItem onClick = { ( ) => handleShare ( 'report' ) } >
347
+ < HStack spacing = { 1 } >
348
+ < TbReport />
349
+ < span > Report HTML</ span >
350
+ </ HStack >
351
+ </ MenuItem >
352
+ </ MenuList >
353
+ </ Menu >
354
+ </ HStack >
281
355
) }
282
356
< div />
283
357
</ Box >
358
+ < div style = { { display : 'none' } } >
359
+ < video ref = { recordRefs . videoRef } />
360
+ < canvas ref = { recordRefs . canvasRef } />
361
+ </ div >
284
362
{ /* <HStack spacing={2}>
285
363
<Switch
286
364
isChecked={fullyAuto}
0 commit comments