11import './App.less' ;
22
3- import { Alert , App as AntdApp , ConfigProvider , Empty , theme } from 'antd' ;
3+ import { CopyOutlined , DownloadOutlined } from '@ant-design/icons' ;
4+ import {
5+ Alert ,
6+ App as AntdApp ,
7+ Button ,
8+ ConfigProvider ,
9+ Empty ,
10+ Segmented ,
11+ Tooltip ,
12+ message ,
13+ theme ,
14+ } from 'antd' ;
415import { useEffect , useMemo , useRef , useState } from 'react' ;
516import { Panel , PanelGroup , PanelResizeHandle } from 'react-resizable-panels' ;
617
718import {
819 GroupedActionDump ,
920 dedupeExecutionsKeepLatest ,
21+ reportToMarkdown ,
1022 restoreImageReferences ,
1123} from '@midscene/core' ;
1224import { antiEscapeScriptTag } from '@midscene/shared/utils' ;
@@ -16,6 +28,7 @@ import {
1628 globalThemeConfig ,
1729 useGlobalPreference ,
1830} from '@midscene/visualizer' ;
31+ import AgentScreenshotView from './components/agent-screenshot-view' ;
1932import DetailPanel from './components/detail-panel' ;
2033import DetailSide from './components/detail-side' ;
2134import GlobalHoverPreview from './components/global-hover-preview' ;
@@ -27,8 +40,15 @@ import ThemeLightIcon from './icons/theme-light.svg?react';
2740import type {
2841 PlaywrightTaskAttributes ,
2942 PlaywrightTasks ,
43+ ReportViewMode ,
3044 VisualizerProps ,
3145} from './types' ;
46+ import {
47+ downloadMarkdownZip ,
48+ getReportMarkdownView ,
49+ markdownArchiveBaseName ,
50+ markdownZipDownloadTooltip ,
51+ } from './utils/markdown-export' ;
3252import { formatModelBriefText } from './utils/model-brief' ;
3353import {
3454 getEmptyDumpDescription ,
@@ -97,6 +117,12 @@ function Visualizer(props: VisualizerProps): JSX.Element {
97117 } ) ;
98118 const dump = useExecutionDump ( ( store ) => store . dump ) ;
99119 const [ timelineCollapsed , setTimelineCollapsed ] = useState ( false ) ;
120+ const [ reportViewMode , setReportViewMode ] = useState < ReportViewMode > ( 'human' ) ;
121+ const [ selectedMarkdownImagePath , setSelectedMarkdownImagePath ] = useState <
122+ string | null
123+ > ( null ) ;
124+ const [ selectedMarkdownImageRequestId , setSelectedMarkdownImageRequestId ] =
125+ useState ( 0 ) ;
100126 const {
101127 modelCallDetailsEnabled : proModeEnabled ,
102128 setModelCallDetailsEnabled : setProModeEnabled ,
@@ -147,6 +173,62 @@ function Visualizer(props: VisualizerProps): JSX.Element {
147173 dumps . every ( ( d ) => d . attributes ?. playwright_test_status === 'skipped' ) ,
148174 [ dumps ] ,
149175 ) ;
176+ const reportMarkdownView = useMemo ( ( ) => {
177+ if ( reportViewMode !== 'markdown' ) {
178+ return null ;
179+ }
180+ return getReportMarkdownView ( dump , reportToMarkdown ) ;
181+ } , [ dump , reportViewMode ] ) ;
182+ const readyReportMarkdown =
183+ reportMarkdownView ?. status === 'ready' ? reportMarkdownView : null ;
184+ const reportArchiveBaseName = useMemo (
185+ ( ) => markdownArchiveBaseName ( dump ) ,
186+ [ dump ] ,
187+ ) ;
188+
189+ const handleCopyReportMarkdown = async ( ) => {
190+ if ( ! readyReportMarkdown ) {
191+ return ;
192+ }
193+
194+ try {
195+ await navigator . clipboard . writeText ( readyReportMarkdown . markdown ) ;
196+ message . success ( 'Markdown copied' ) ;
197+ } catch {
198+ message . error ( 'Copy failed' ) ;
199+ }
200+ } ;
201+
202+ const handleDownloadReportMarkdownZip = async ( ) => {
203+ if ( ! readyReportMarkdown ) {
204+ return ;
205+ }
206+
207+ try {
208+ const result = await downloadMarkdownZip (
209+ readyReportMarkdown . markdown ,
210+ readyReportMarkdown . attachments ,
211+ reportArchiveBaseName ,
212+ ) ;
213+ if ( result . missingAttachmentCount > 0 ) {
214+ message . warning (
215+ `${ result . missingAttachmentCount } screenshot link${
216+ result . missingAttachmentCount === 1 ? '' : 's'
217+ } kept as markdown reference${
218+ result . missingAttachmentCount === 1 ? '' : 's'
219+ } but not packaged.`,
220+ ) ;
221+ } else {
222+ message . success ( 'Markdown and images downloaded' ) ;
223+ }
224+ } catch {
225+ message . error ( 'Download failed' ) ;
226+ }
227+ } ;
228+ const handleMarkdownImageClick = ( markdownPath : string ) => {
229+ setSelectedMarkdownImagePath ( markdownPath ) ;
230+ setSelectedMarkdownImageRequestId ( ( current ) => current + 1 ) ;
231+ } ;
150232
151233 const renderContent = ( ) => {
152234 if ( dump && dump . executions . length === 0 ) {
@@ -228,6 +310,9 @@ function Visualizer(props: VisualizerProps): JSX.Element {
228310 onProModeChange = { setProModeEnabled }
229311 replayAllScripts = { replayAllScripts }
230312 setReplayAllMode = { setReplayAllMode }
313+ reportViewMode = { reportViewMode }
314+ reportMarkdownView = { reportMarkdownView }
315+ onMarkdownImageClick = { handleMarkdownImageClick }
231316 />
232317 </ div >
233318 < div
@@ -252,28 +337,38 @@ function Visualizer(props: VisualizerProps): JSX.Element {
252337 } }
253338 />
254339 < div className = "main-right" >
255- < div
256- className = "main-right-header"
257- onClick = { ( ) => setTimelineCollapsed ( ! timelineCollapsed ) }
258- style = { { cursor : 'pointer' , userSelect : 'none' } }
259- >
260- < span
261- className = "timeline-collapse-icon"
262- style = { {
263- display : 'inline-block' ,
264- marginRight : 8 ,
265- transition : 'transform 0.2s' ,
266- transform : timelineCollapsed
267- ? 'rotate(-90deg)'
268- : 'rotate(0deg)' ,
269- } }
270- >
271- ▼
272- </ span >
273- Record
274- </ div >
275- { ! timelineCollapsed && < Timeline key = { mainLayoutChangeFlag } /> }
276- < div className = "main-content" > { content } </ div >
340+ { reportViewMode === 'markdown' ? (
341+ < AgentScreenshotView
342+ markdownView = { reportMarkdownView }
343+ selectedMarkdownImagePath = { selectedMarkdownImagePath }
344+ selectedMarkdownImageRequestId = { selectedMarkdownImageRequestId }
345+ />
346+ ) : (
347+ < >
348+ < div
349+ className = "main-right-header"
350+ onClick = { ( ) => setTimelineCollapsed ( ! timelineCollapsed ) }
351+ style = { { cursor : 'pointer' , userSelect : 'none' } }
352+ >
353+ < span
354+ className = "timeline-collapse-icon"
355+ style = { {
356+ display : 'inline-block' ,
357+ marginRight : 8 ,
358+ transition : 'transform 0.2s' ,
359+ transform : timelineCollapsed
360+ ? 'rotate(-90deg)'
361+ : 'rotate(0deg)' ,
362+ } }
363+ >
364+ ▼
365+ </ span >
366+ Record
367+ </ div >
368+ { ! timelineCollapsed && < Timeline key = { mainLayoutChangeFlag } /> }
369+ < div className = "main-content" > { content } </ div >
370+ </ >
371+ ) }
277372 </ div >
278373 </ div >
279374 ) ;
@@ -327,6 +422,46 @@ function Visualizer(props: VisualizerProps): JSX.Element {
327422 < div className = "page-nav" >
328423 < div className = "page-nav-left" >
329424 < Logo />
425+ { executionDump && (
426+ < Segmented
427+ size = "small"
428+ className = "report-view-mode-switch"
429+ value = { reportViewMode }
430+ options = { [
431+ { label : 'Human View' , value : 'human' } ,
432+ { label : 'Markdown View' , value : 'markdown' } ,
433+ ] }
434+ onChange = { ( value ) => {
435+ setReportViewMode ( value as ReportViewMode ) ;
436+ setSelectedMarkdownImagePath ( null ) ;
437+ setSelectedMarkdownImageRequestId ( 0 ) ;
438+ } }
439+ />
440+ ) }
441+ { reportViewMode === 'markdown' && (
442+ < div className = "report-markdown-actions" >
443+ < Tooltip title = "Copy report.md markdown" >
444+ < Button
445+ type = "text"
446+ size = "small"
447+ icon = { < CopyOutlined /> }
448+ disabled = { ! readyReportMarkdown }
449+ onClick = { ( ) => void handleCopyReportMarkdown ( ) }
450+ aria-label = "Copy report markdown"
451+ />
452+ </ Tooltip >
453+ < Tooltip title = { markdownZipDownloadTooltip } >
454+ < Button
455+ type = "text"
456+ size = "small"
457+ icon = { < DownloadOutlined /> }
458+ disabled = { ! readyReportMarkdown }
459+ onClick = { ( ) => void handleDownloadReportMarkdownZip ( ) }
460+ aria-label = "Download markdown and images ZIP"
461+ />
462+ </ Tooltip >
463+ </ div >
464+ ) }
330465 </ div >
331466 < div className = "page-nav-right" >
332467 < div className = "page-nav-version" >
0 commit comments