2323from panel .viewable import Viewable , Viewer
2424from panel_material_ui import (
2525 Accordion , BreakpointSwitcher , Button , Card , ChatFeed , ChatMessage ,
26- Container , Dialog , Divider , FileDownload , IconButton , MenuButton , Progress ,
27- Select , SpeedDial , TextAreaInput , TextInput , Typography ,
26+ Checkbox , Container , Dialog , Divider , FileDownload , IconButton , MenuButton ,
27+ Progress , Select , SpeedDial , TextAreaInput , TextInput , Typography ,
2828)
2929
3030from ..views .base import Panel , View
4949)
5050
5151
52+ def _export_checkbox (task , status_source , align = "center" ):
53+ """
54+ Checkbox controlling whether a task's outputs are kept when the report is
55+ exported. ``status_source`` supplies the status it tracks, so it only
56+ appears once that has something to export.
57+ """
58+ return Checkbox .from_param (
59+ task .param .include_in_export ,
60+ label = "" ,
61+ align = align ,
62+ margin = (0 , 4 , 0 , 0 ),
63+ # No description: it would render an info icon; the report explains the
64+ # checkboxes at the top instead.
65+ description = "" ,
66+ visible = param .bind (
67+ lambda status , views : (
68+ status in ("success" , "error" , "cancelled" ) or bool (views )
69+ ),
70+ status_source .param .status , status_source .param .views ,
71+ ),
72+ )
73+
74+
75+ def _export_header (section , status_source , variant = "h3" ):
76+ """Heading for a section, with the checkbox that keeps or discards it."""
77+ title = Typography (section .param .title , variant = variant , margin = 0 )
78+ return Row (
79+ _export_checkbox (section , status_source ), title ,
80+ align = "center" , sizing_mode = "stretch_width" ,
81+ )
82+
83+
5284class Task (Viewer ):
5385 """
5486 A `Task` defines a single unit of work that can be executed and rendered.
@@ -63,6 +95,9 @@ class Task(Viewer):
6395 history = param .List (doc = """
6496 Conversation history to include as context for the task.""" )
6597
98+ include_in_export = param .Boolean (default = True , doc = """
99+ Whether this task's outputs are included when the report is exported.""" )
100+
66101 instruction = param .String (default = "" , doc = """
67102 The instruction to give to the task.""" )
68103
@@ -625,61 +660,6 @@ def reset(self, start: int = 0):
625660 task .reset ()
626661 self ._populate_view ()
627662
628- def to_notebook (self ):
629- """
630- Returns the notebook representation of the tasks.
631- """
632- if len (self ) and not self .status == "success" :
633- raise RuntimeError (
634- "Report has not been executed, run report before exporting to_notebook."
635- )
636- cells , extensions = [], ['tabulator' ]
637- pending_headers = []
638- for out in self .views :
639- cell = ext = None
640- if isinstance (out , Typography ):
641- # Buffer headers; only emit them if followed by exportable content
642- level = int (out .variant [1 :]) if out .variant and out .variant .startswith ('h' ) else 0
643- prefix = f"{ '#' * level } " if level else ''
644- pending_headers .append (make_md_cell (f"{ prefix } { out .object } " ))
645- continue
646- elif isinstance (out , Markdown ):
647- cell = make_md_cell (out .object )
648- elif isinstance (out , LumenEditor ):
649- cell , ext = format_output (out )
650- elif isinstance (out , str ):
651- cell = make_md_cell (out )
652- elif isinstance (out , ChatMessage ):
653- obj = out .object
654- if isinstance (obj , str ):
655- cell = make_md_cell (obj )
656- elif isinstance (obj , Markdown ):
657- cell = make_md_cell (obj .object )
658- # Skip non-LumenEditor Viewables (not meaningfully exportable)
659-
660- if cell is None :
661- continue
662- # Flush buffered headers before the content cell
663- cells .extend (pending_headers )
664- pending_headers .clear ()
665- cells .append (cell )
666- if ext and ext not in extensions :
667- extensions .append (ext )
668- cells = make_preamble ("" , extensions = extensions ) + cells
669- return write_notebook (cells )
670-
671- def to_html (self ):
672- """
673- Returns the HTML representation of the report.
674- """
675- if len (self ) and self .status != "success" :
676- raise RuntimeError (
677- "Report has not been executed, run report before exporting to html."
678- )
679- buf = io .StringIO ()
680- self ._view .save (buf , title = self .title or "Report" )
681- return buf .getvalue ()
682-
683663 def validate (
684664 self ,
685665 context : TContext | None = None ,
@@ -823,7 +803,36 @@ def _populate_view(self):
823803 placeholders = [self ._placeholder ]
824804 if self ._task_previews is not None :
825805 placeholders .append (self ._task_previews )
826- self ._view [:] = self ._header + placeholders + list (self ._tasks )
806+ self ._view [:] = self ._header + placeholders + [self ._task_view (task ) for task in self ._tasks ]
807+
808+ def _task_view (self , task ):
809+ """
810+ Give every task its own export checkbox, so a single chart can be
811+ dropped without discarding the whole section it came from. The report
812+ only draws headers for its top level sections, so without this nothing
813+ inside one could be selected.
814+
815+ A nested section gets a heading too, unless it takes its parent's title
816+ (a section wrapping a single plan does), which would repeat the heading
817+ the report already shows.
818+ """
819+ if isinstance (task , Section ):
820+ if task .title == self .title :
821+ return task
822+ return Column (
823+ _export_header (task , task , variant = "h4" ),
824+ task ,
825+ sizing_mode = "stretch_width" ,
826+ styles = {'min-height' : 'unset' },
827+ height_policy = 'fit' ,
828+ )
829+ return Row (
830+ _export_checkbox (task , task , align = "start" ),
831+ task ,
832+ sizing_mode = "stretch_width" ,
833+ styles = {'min-height' : 'unset' },
834+ height_policy = 'fit' ,
835+ )
827836
828837 async def _run_task (self , i : int , task : Task | Actor , context : TContext | None , ** kwargs ) -> list [Any ]:
829838 if context is not None :
@@ -914,6 +923,7 @@ def from_views(cls, views: list, title: str | None = None) -> Report:
914923 return report
915924
916925 def _init_view (self ):
926+ self ._section_headers = {}
917927 self ._header_title = Typography (
918928 self .param .title , variant = "h1" , margin = (0 , 0 , 0 , 10 )
919929 )
@@ -1021,7 +1031,14 @@ def _init_view(self):
10211031 large = self ._menu ,
10221032 sizing_mode = "stretch_width"
10231033 )
1034+ self ._export_hint = Typography (
1035+ "Untick a chart, table or note to leave it out of the exported report." ,
1036+ variant = "body2" , margin = (0 , 10 , 10 , 10 ),
1037+ sx = {"color" : "text.secondary" },
1038+ visible = self .param .status .rx ().rx .in_ (("success" , "error" , "cancelled" )),
1039+ )
10241040 self ._container = Column (
1041+ self ._export_hint ,
10251042 self ._view ,
10261043 self ._dialog ,
10271044 margin = (0 , 0 , 0 , 5 ),
@@ -1094,6 +1111,104 @@ def _handle_cancel(self, event=None):
10941111 if self ._active_task is not None and not self ._active_task .done ():
10951112 self ._active_task .cancel ()
10961113
1114+ @property
1115+ def _export_views (self ):
1116+ # Task-less reports (e.g. Report.from_views) populate ``views`` directly.
1117+ if not len (self ):
1118+ return self .views
1119+ return self ._selected_views (self )
1120+
1121+ def _selected_views (self , group , include_header = True ):
1122+ """
1123+ The views of a task group, skipping sections discarded from the export.
1124+ Walks the tasks rather than reading ``group.views`` so that a nested
1125+ section's selection is honoured, not just a top level one's.
1126+ """
1127+ views = list (group ._header ) if include_header else []
1128+ for task in group :
1129+ if not task .include_in_export :
1130+ continue
1131+ views += self ._selected_views (task ) if isinstance (task , TaskGroup ) else list (task .views )
1132+ return views
1133+
1134+ def _export_view (self ):
1135+ # Task-less reports (e.g. Report.from_views) populate ``_view`` directly.
1136+ if not len (self ):
1137+ return self ._view
1138+ # Build each card from the section's selected views rather than the
1139+ # section itself, so a discarded nested section is left out of the HTML
1140+ # too. The card header already shows the title, hence no heading.
1141+ cards = [
1142+ (
1143+ section .title ,
1144+ Column (* self ._selected_views (section , include_header = False ), sizing_mode = "stretch_width" ),
1145+ )
1146+ for section in self if section .include_in_export
1147+ ]
1148+ return Accordion (
1149+ * cards ,
1150+ active = list (range (len (cards ))),
1151+ sizing_mode = "stretch_width" ,
1152+ min_height = 0 ,
1153+ margin = (0 , 5 , 15 , 5 ),
1154+ sx = self ._view .sx ,
1155+ )
1156+
1157+ def to_notebook (self ):
1158+ """
1159+ Returns the notebook representation of the report.
1160+ """
1161+ if len (self ) and not self .status == "success" :
1162+ raise RuntimeError (
1163+ "Report has not been executed, run report before exporting to_notebook."
1164+ )
1165+ cells , extensions = [], ['tabulator' ]
1166+ pending_headers = []
1167+ for out in self ._export_views :
1168+ cell = ext = None
1169+ if isinstance (out , Typography ):
1170+ # Buffer headers; only emit them if followed by exportable content
1171+ level = int (out .variant [1 :]) if out .variant and out .variant .startswith ('h' ) else 0
1172+ prefix = f"{ '#' * level } " if level else ''
1173+ pending_headers .append (make_md_cell (f"{ prefix } { out .object } " ))
1174+ continue
1175+ elif isinstance (out , Markdown ):
1176+ cell = make_md_cell (out .object )
1177+ elif isinstance (out , LumenEditor ):
1178+ cell , ext = format_output (out )
1179+ elif isinstance (out , str ):
1180+ cell = make_md_cell (out )
1181+ elif isinstance (out , ChatMessage ):
1182+ obj = out .object
1183+ if isinstance (obj , str ):
1184+ cell = make_md_cell (obj )
1185+ elif isinstance (obj , Markdown ):
1186+ cell = make_md_cell (obj .object )
1187+ # Skip non-LumenEditor Viewables (not meaningfully exportable)
1188+
1189+ if cell is None :
1190+ continue
1191+ # Flush buffered headers before the content cell
1192+ cells .extend (pending_headers )
1193+ pending_headers .clear ()
1194+ cells .append (cell )
1195+ if ext and ext not in extensions :
1196+ extensions .append (ext )
1197+ cells = make_preamble ("" , extensions = extensions ) + cells
1198+ return write_notebook (cells )
1199+
1200+ def to_html (self ):
1201+ """
1202+ Returns the HTML representation of the report.
1203+ """
1204+ if len (self ) and self .status != "success" :
1205+ raise RuntimeError (
1206+ "Report has not been executed, run report before exporting to html."
1207+ )
1208+ buf = io .StringIO ()
1209+ self ._export_view ().save (buf , title = self .title or "Report" )
1210+ return buf .getvalue ()
1211+
10971212 async def _export_report (self , item = None ):
10981213 if len (self ) and self .status not in ("success" , "cancelled" , "error" ):
10991214 await self .execute ()
@@ -1116,8 +1231,19 @@ def _expand_all(self, event=None):
11161231 def _open_settings (self , event = None ):
11171232 self ._dialog .open = True
11181233
1234+ def _section_header (self , section ):
1235+ """Card header with a checkbox to keep/discard the section on export."""
1236+ return _export_header (section , self )
1237+
11191238 def _populate_view (self ):
1120- self ._view [:] = objects = [(task .title , task ) for task in self ]
1239+ headers = {}
1240+ objects = []
1241+ for section in self :
1242+ header = self ._section_headers .get (section ) or self ._section_header (section )
1243+ headers [section ] = header
1244+ objects .append ((header , section ))
1245+ self ._section_headers = headers
1246+ self ._view [:] = objects
11211247 has_outputs = self .status in ("success" , "error" , "cancelled" ) or bool (self .views )
11221248 if has_outputs :
11231249 self ._view .active = list (range (len (objects )))
0 commit comments