fraclab-sdk 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ """Run page: edit params for existing runs and execute."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ import streamlit as st
8
+
9
+ from fraclab_sdk.algorithm import AlgorithmLibrary
10
+ from fraclab_sdk.config import SDKConfig
11
+ from fraclab_sdk.run import RunManager, RunStatus
12
+ from fraclab_sdk.workbench import ui_styles
13
+
14
+ st.set_page_config(page_title="Run", page_icon="▶️", layout="wide")
15
+ st.title("Run")
16
+
17
+ ui_styles.apply_global_styles()
18
+
19
+ # --- Page-Specific CSS ---
20
+ st.markdown("""
21
+ <style>
22
+ /* Compact form labels */
23
+ div[data-testid="stNumberInput"] label,
24
+ div[data-testid="stTextInput"] label,
25
+ div[data-testid="stCheckbox"] label {
26
+ margin-bottom: 0px !important;
27
+ font-size: 0.85rem !important;
28
+ color: #666 !important;
29
+ }
30
+
31
+ /* Divider spacing */
32
+ hr { margin-top: 1rem; margin-bottom: 1rem; }
33
+ </style>
34
+ """, unsafe_allow_html=True)
35
+
36
+ config = SDKConfig()
37
+ run_mgr = RunManager(config)
38
+ algo_lib = AlgorithmLibrary(config)
39
+
40
+
41
+ # --- Intelligent Layout Engine ---
42
+
43
+ def _is_compact_field(schema: dict) -> bool:
44
+ """Determine if a field is small enough to fit in a grid column."""
45
+ ftype = schema.get("type")
46
+ # Numbers, Booleans, and short Strings (without enums/long defaults) are compact
47
+ if ftype in ["integer", "number", "boolean"]:
48
+ return True
49
+ if ftype == "string" and len(str(schema.get("default", ""))) < 50:
50
+ return True
51
+ return False
52
+
53
+ def render_field_widget(key: str, schema: dict, value: Any, path: str) -> Any:
54
+ """Render a single widget based on schema type."""
55
+ ftype = schema.get("type")
56
+ title = schema.get("title") or key
57
+ # Simplify label: if title is camelCase, maybe split it? For now use title directly.
58
+ # description = schema.get("description") # Tooltip is enough, don't clutter UI text
59
+
60
+ default_val = schema.get("default")
61
+ help_text = schema.get("description")
62
+
63
+ if ftype == "string":
64
+ val = value if value is not None else (default_val or "")
65
+ return st.text_input(title, value=val, help=help_text, key=path)
66
+
67
+ if ftype == "number":
68
+ val = value if value is not None else default_val
69
+ return st.number_input(title, value=float(val or 0.0), help=help_text, key=path)
70
+
71
+ if ftype == "integer":
72
+ val = value if value is not None else default_val
73
+ return int(st.number_input(title, value=int(val or 0), step=1, help=help_text, key=path))
74
+
75
+ if ftype == "boolean":
76
+ val = value if value is not None else default_val
77
+ # Toggle looks better than checkbox in grid
78
+ return st.toggle(title, value=bool(val), help=help_text, key=path)
79
+
80
+ if ftype == "array":
81
+ # Arrays are complex, stick to full width expansion
82
+ return _render_json_editor(title, value, default_val, help_text, path)
83
+
84
+ if ftype == "object":
85
+ # Nested object -> Recursive layout
86
+ with st.container(border=True):
87
+ st.markdown(f"**{title}**")
88
+ props = schema.get("properties", {})
89
+ obj = value if isinstance(value, dict) else (default_val if isinstance(default_val, dict) else {})
90
+
91
+ # Recursive call to grid layout
92
+ return render_schema_grid(props, obj, path)
93
+
94
+ # Fallback
95
+ return _render_json_editor(title, value, default_val, help_text, path)
96
+
97
+ def _render_json_editor(title, value, default, help_text, path):
98
+ """Helper for raw JSON fields."""
99
+ st.markdown(f"<small>{title}</small>", unsafe_allow_html=True)
100
+ current = value if value is not None else (default if default is not None else [])
101
+ text = st.text_area(
102
+ title,
103
+ value=json.dumps(current, indent=2, ensure_ascii=False),
104
+ help=f"{help_text} (Edit as JSON)",
105
+ key=path,
106
+ label_visibility="collapsed",
107
+ height=100
108
+ )
109
+ try:
110
+ return json.loads(text) if text.strip() else current
111
+ except Exception:
112
+ return current
113
+
114
+ def render_schema_grid(properties: Dict[str, dict], current_values: Dict[str, Any], prefix: str) -> Dict[str, Any]:
115
+ """
116
+ Renders fields in a smart grid layout:
117
+ - Compact fields (numbers, bools) get packed into columns (up to 4).
118
+ - Wide fields (objects, arrays) break the line and take full width.
119
+ """
120
+ result = {}
121
+
122
+ # 1. Separate fields into groups to maintain partial order while grid-packing
123
+ # Strategy: Iterate and buffer compact fields. Flush buffer when a wide field hits.
124
+
125
+ compact_buffer = [] # List of (key, schema)
126
+
127
+ def flush_buffer():
128
+ if not compact_buffer:
129
+ return
130
+
131
+ # Calculate optimal columns (max 4, min 2)
132
+ n_items = len(compact_buffer)
133
+ n_cols = 4 if n_items >= 4 else (n_items if n_items > 0 else 1)
134
+
135
+ # Split into rows if > 4 items? Simple logic: Just wrap
136
+ # Actually st.columns handles wrapping poorly, better to batch by 4
137
+
138
+ for i in range(0, n_items, 4):
139
+ batch = compact_buffer[i : i+4]
140
+ cols = st.columns(len(batch))
141
+ for col, (b_key, b_schema) in zip(cols, batch):
142
+ with col:
143
+ val = current_values.get(b_key)
144
+ result[b_key] = render_field_widget(b_key, b_schema, val, f"{prefix}.{b_key}")
145
+
146
+ compact_buffer.clear()
147
+
148
+ for key, prop_schema in properties.items():
149
+ if _is_compact_field(prop_schema):
150
+ compact_buffer.append((key, prop_schema))
151
+ else:
152
+ # Wide field encountered: flush buffer first
153
+ flush_buffer()
154
+ # Render wide field
155
+ val = current_values.get(key)
156
+ result[key] = render_field_widget(key, prop_schema, val, f"{prefix}.{key}")
157
+
158
+ # Final flush
159
+ flush_buffer()
160
+
161
+ # Preserve extra keys in current_values that aren't in schema
162
+ for k, v in current_values.items():
163
+ if k not in result:
164
+ result[k] = v
165
+
166
+ return result
167
+
168
+
169
+ def load_params(run_dir: Path) -> dict:
170
+ path = run_dir / "input" / "params.json"
171
+ try:
172
+ return json.loads(path.read_text())
173
+ except Exception:
174
+ return {}
175
+
176
+
177
+ # ==========================================
178
+ # Main Logic
179
+ # ==========================================
180
+
181
+ runs = run_mgr.list_runs()
182
+
183
+ if not runs:
184
+ st.info("No runs available. Create a run from the Selection page.")
185
+ st.stop()
186
+
187
+ pending_runs = [r for r in runs if r.status == RunStatus.PENDING]
188
+ other_runs = [r for r in runs if r.status != RunStatus.PENDING]
189
+
190
+ # ------------------------------------------
191
+ # 1. Pending Runs (Editor Workspace)
192
+ # ------------------------------------------
193
+ st.subheader("Pending Runs")
194
+
195
+ if not pending_runs:
196
+ st.info("No pending runs waiting for execution.")
197
+ else:
198
+ # Use tabs for context switching
199
+ tabs = st.tabs([f"⚙️ {run.run_id}" for run in pending_runs])
200
+
201
+ for tab, run in zip(tabs, pending_runs):
202
+ with tab:
203
+ run_dir = run_mgr.get_run_dir(run.run_id)
204
+ algo_handle = algo_lib.get_algorithm(run.algorithm_id, run.algorithm_version)
205
+ schema = algo_handle.params_schema
206
+ current_params = load_params(run_dir)
207
+
208
+ # --- Layout: Top Info Bar ---
209
+ with st.container(border=True):
210
+ c1, c2, c3, c4 = st.columns([2, 2, 2, 1])
211
+ with c1: st.caption(f"**Snapshot:** `{run.snapshot_id}`")
212
+ with c2: st.caption(f"**Algo:** `{run.algorithm_id}` v{run.algorithm_version}")
213
+ with c3: st.caption(f"**Created:** {run.created_at}")
214
+ with c4:
215
+ # Timeout setting tucked away here
216
+ timeout = st.number_input("Timeout (s)", value=300, step=10, key=f"to_{run.run_id}", label_visibility="collapsed")
217
+
218
+ # --- Layout: Parameters Grid ---
219
+ st.markdown("##### Parameters")
220
+ with st.container(border=True):
221
+ if schema.get("type") == "object":
222
+ props = schema.get("properties", {})
223
+ # CALL THE GRID ENGINE
224
+ new_params = render_schema_grid(props, current_params, prefix=f"run_{run.run_id}")
225
+ else:
226
+ st.info("Schema is not an object, editing raw JSON.")
227
+ new_params = _render_json_editor("Raw Params", current_params, {}, "", f"run_raw_{run.run_id}")
228
+
229
+ st.divider()
230
+
231
+ # --- Layout: Action Footer ---
232
+ # Right-aligned actions
233
+ _, col_btns = st.columns([3, 4])
234
+ with col_btns:
235
+ b1, b2, b3 = st.columns([1, 1, 1.5], gap="small")
236
+
237
+ with b1:
238
+ if st.button("🚫 Cancel", key=f"cancel_{run.run_id}", width="stretch"):
239
+ try:
240
+ run_mgr.delete_run(run.run_id)
241
+ st.success("Cancelled")
242
+ st.rerun()
243
+ except Exception as e:
244
+ st.error(f"Error: {e}")
245
+
246
+ with b2:
247
+ if st.button("💾 Save", key=f"save_{run.run_id}", width="stretch"):
248
+ try:
249
+ (run_dir / "input").mkdir(parents=True, exist_ok=True)
250
+ (run_dir / "input" / "params.json").write_text(
251
+ json.dumps(new_params, indent=2, ensure_ascii=False),
252
+ encoding="utf-8",
253
+ )
254
+ st.toast("Parameters saved successfully!", icon="💾")
255
+ except Exception as e:
256
+ st.error(f"Save failed: {e}")
257
+
258
+ with b3:
259
+ if st.button("▶️ Run Algorithm", key=f"exec_{run.run_id}", type="primary", width="stretch"):
260
+ try:
261
+ # Auto-save before run
262
+ (run_dir / "input").mkdir(parents=True, exist_ok=True)
263
+ (run_dir / "input" / "params.json").write_text(
264
+ json.dumps(new_params, indent=2, ensure_ascii=False),
265
+ encoding="utf-8",
266
+ )
267
+
268
+ with st.spinner("Initializing execution..."):
269
+ result = run_mgr.execute(run.run_id, timeout_s=int(timeout))
270
+
271
+ if result.error:
272
+ st.error(f"Run Finished: {result.status.value}\n{result.error}")
273
+ else:
274
+ # Navigate to Results page with executed run
275
+ st.session_state.executed_run_id = run.run_id
276
+ st.switch_page("pages/5_Results.py")
277
+ except Exception as e:
278
+ st.error(f"Execution Exception: {e}")
279
+
280
+
281
+ # ------------------------------------------
282
+ # 2. History (Other Runs)
283
+ # ------------------------------------------
284
+ st.subheader("Run History")
285
+
286
+ if not other_runs:
287
+ st.caption("No historical runs.")
288
+
289
+ other_runs_reversed = other_runs[::-1]
290
+
291
+ for run in other_runs_reversed:
292
+ status_config = {
293
+ RunStatus.PENDING: ("⏳", "Pending", "gray"),
294
+ RunStatus.RUNNING: ("🔄", "Running", "blue"),
295
+ RunStatus.SUCCEEDED: ("✅", "Succeeded", "green"),
296
+ RunStatus.FAILED: ("❌", "Failed", "red"),
297
+ RunStatus.TIMEOUT: ("⏱️", "Timeout", "orange"),
298
+ }
299
+ icon, label, color = status_config.get(run.status, ("❓", "Unknown", "gray"))
300
+
301
+ with st.expander(f"{icon} {run.run_id}", expanded=False):
302
+ with st.container(border=True):
303
+ # Info
304
+ c1, c2, c3 = st.columns([3, 2, 2])
305
+ with c1:
306
+ st.caption("Context")
307
+ st.markdown(f"**{run.algorithm_id}** v{run.algorithm_version}")
308
+ st.text(f"Snap: {run.snapshot_id}")
309
+ with c2:
310
+ st.caption("Timing")
311
+ st.text(f"Start: {run.started_at or '--'}")
312
+ st.text(f"End: {run.completed_at or '--'}")
313
+ with c3:
314
+ st.caption("Status")
315
+ st.markdown(f":{color}[**{label}**]")
316
+ if run.error:
317
+ st.error(run.error)
318
+
319
+ # Params Read-only
320
+ st.divider()
321
+ st.caption("Run Parameters")
322
+ run_dir = run_mgr.get_run_dir(run.run_id)
323
+ params_view = load_params(run_dir)
324
+ if params_view:
325
+ st.code(json.dumps(params_view, indent=2, ensure_ascii=False), language="json")
@@ -0,0 +1,292 @@
1
+ """Results viewing page."""
2
+
3
+ import json
4
+
5
+ import streamlit as st
6
+
7
+ from fraclab_sdk.config import SDKConfig
8
+ from fraclab_sdk.algorithm import AlgorithmLibrary
9
+ from fraclab_sdk.results import (
10
+ ResultReader,
11
+ get_artifact_preview_type,
12
+ preview_image,
13
+ preview_json_raw,
14
+ preview_json_table,
15
+ preview_scalar,
16
+ )
17
+ from fraclab_sdk.run import RunManager, RunStatus
18
+ from fraclab_sdk.workbench import ui_styles
19
+
20
+ st.set_page_config(page_title="Results", page_icon="📊", layout="wide")
21
+ st.title("Results")
22
+
23
+ ui_styles.apply_global_styles()
24
+
25
+
26
+ def get_manager():
27
+ """Get run manager."""
28
+ config = SDKConfig()
29
+ return RunManager(config)
30
+
31
+
32
+ run_manager = get_manager()
33
+ algo_lib = AlgorithmLibrary(run_manager._config)
34
+ runs = run_manager.list_runs()
35
+
36
+ if not runs:
37
+ st.info("No runs available.")
38
+ st.stop()
39
+
40
+ # ==========================================
41
+ # 1. Run Selection & Status
42
+ # ==========================================
43
+
44
+ # Prepare options
45
+ run_options = {r.run_id: r for r in runs}
46
+ # Sort by latest first usually makes sense
47
+ run_ids = list(reversed(list(run_options.keys())))
48
+
49
+ # Check for navigation context
50
+ default_run_id = st.session_state.pop("executed_run_id", None) or st.session_state.pop("created_run_id", None)
51
+ default_index = run_ids.index(default_run_id) if default_run_id in run_ids else 0
52
+
53
+ with st.container(border=True):
54
+ col_sel, col_stat = st.columns([4, 1])
55
+
56
+ with col_sel:
57
+ selected_run_id = st.selectbox(
58
+ "Select Run",
59
+ options=run_ids,
60
+ index=default_index,
61
+ format_func=lambda x: f"{x} — {run_options[x].algorithm_id} (v{run_options[x].algorithm_version})",
62
+ label_visibility="collapsed"
63
+ )
64
+
65
+ with col_stat:
66
+ if selected_run_id:
67
+ status = run_options[selected_run_id].status
68
+ status_color = {
69
+ RunStatus.SUCCEEDED: "green",
70
+ RunStatus.FAILED: "red",
71
+ RunStatus.PENDING: "gray",
72
+ RunStatus.RUNNING: "blue"
73
+ }.get(status, "gray")
74
+ st.markdown(f"<div style='text-align:center; padding: 8px; border: 1px solid #ddd; border-radius: 6px;'>Status: <b style='color:{status_color}'>{status.value}</b></div>", unsafe_allow_html=True)
75
+
76
+ if not selected_run_id:
77
+ st.stop()
78
+
79
+ run = run_options[selected_run_id]
80
+ run_dir = run_manager.get_run_dir(selected_run_id)
81
+ reader = ResultReader(run_dir)
82
+
83
+ # ==========================================
84
+ # 2. Run Context
85
+ # ==========================================
86
+
87
+ def _load_output_contract(algo_id: str, algo_version: str):
88
+ """Load output_contract.json from algorithm directory."""
89
+ try:
90
+ handle = algo_lib.get_algorithm(algo_id, algo_version)
91
+ manifest_path = handle.directory / "manifest.json"
92
+ manifest_data = {}
93
+ if manifest_path.exists():
94
+ manifest_data = json.loads(manifest_path.read_text())
95
+ files = manifest_data.get("files") or {}
96
+ rel = files.get("outputContractPath", "dist/output_contract.json")
97
+ contract_path = (handle.directory / rel).resolve()
98
+ if not contract_path.exists():
99
+ return None
100
+ return json.loads(contract_path.read_text())
101
+ except Exception:
102
+ return None
103
+
104
+ output_contract = _load_output_contract(run.algorithm_id, run.algorithm_version)
105
+
106
+ with st.expander("ℹ️ Run Metadata", expanded=False):
107
+ c1, c2, c3 = st.columns(3)
108
+ with c1:
109
+ st.caption("Snapshot ID")
110
+ st.code(run.snapshot_id, language="text")
111
+ with c2:
112
+ st.caption("Algorithm ID")
113
+ st.code(run.algorithm_id, language="text")
114
+ with c3:
115
+ st.caption("Timestamps")
116
+ st.text(f"Start: {run.started_at}\nEnd: {run.completed_at}")
117
+
118
+ if run.error:
119
+ st.error(f"Run Error: {run.error}")
120
+ elif reader.has_manifest() and reader.get_error():
121
+ st.error(f"Manifest Error: {reader.get_error()}")
122
+
123
+ st.divider()
124
+
125
+ # ==========================================
126
+ # 3. Artifacts (Default Expanded)
127
+ # ==========================================
128
+ st.subheader("Artifacts")
129
+
130
+ if not reader.has_manifest():
131
+ st.warning("⚠️ Output manifest not found (Run may have failed or produced no output)")
132
+ else:
133
+ manifest = reader.read_manifest()
134
+
135
+ if not manifest.datasets:
136
+ st.info("No artifacts produced.")
137
+ else:
138
+ for ds in manifest.datasets:
139
+ # Match with contract
140
+ contract_ds = None
141
+ if output_contract:
142
+ contract_ds = next((d for d in output_contract.get("datasets", []) if d.get("key") == ds.datasetKey), None)
143
+
144
+ header = f"📂 {ds.datasetKey}"
145
+ if contract_ds and contract_ds.get("role"):
146
+ header += f" ({contract_ds.get('role')})"
147
+
148
+ # --- DATASET LEVEL: EXPANDED BY DEFAULT ---
149
+ with st.expander(header, expanded=True):
150
+ # Contract Info Bar
151
+ if contract_ds:
152
+ st.caption(
153
+ f"**Schema Definition:** Kind=`{contract_ds.get('kind')}` • "
154
+ f"Owner=`{contract_ds.get('owner')}` • "
155
+ f"Card=`{contract_ds.get('cardinality')}`"
156
+ )
157
+
158
+ # --- ITEMS LEVEL: CARDS (Always Visible) ---
159
+ for item in ds.items:
160
+ art = item.artifact
161
+ preview_type = get_artifact_preview_type(art)
162
+
163
+ with st.container(border=True):
164
+ # Item Header & Metadata
165
+ m1, m2, m3 = st.columns([2, 2, 3])
166
+ with m1:
167
+ st.markdown(f"**Item:** `{item.itemKey or art.artifactKey}`")
168
+ with m2:
169
+ st.caption(f"Type: `{art.artifactType}`")
170
+ with m3:
171
+ if art.mimeType: st.caption(f"MIME: `{art.mimeType}`")
172
+
173
+ # [Modified] 删除了 Owner 和 Dims 的显示
174
+ st.markdown("---")
175
+
176
+ # Content Preview
177
+ if preview_type == "scalar":
178
+ value = preview_scalar(art)
179
+ st.metric(label="Value", value=value)
180
+
181
+ elif preview_type == "image":
182
+ image_path = preview_image(art)
183
+ if image_path and image_path.exists():
184
+ # [Modified] use_column_width=True -> width="stretch"
185
+ st.image(str(image_path), caption=art.artifactKey, width="stretch")
186
+ else:
187
+ st.warning("Image file missing")
188
+
189
+ elif preview_type == "json_table":
190
+ table_data = preview_json_table(art)
191
+ if table_data:
192
+ # Use static table for cleaner look
193
+ st.table([dict(zip(table_data["columns"], row)) for row in table_data["rows"]])
194
+ else:
195
+ st.warning("Invalid table data")
196
+
197
+ elif preview_type == "json_raw":
198
+ json_content = preview_json_raw(art)
199
+ if json_content:
200
+ st.code(json_content, language="json")
201
+ else:
202
+ st.warning("Empty JSON")
203
+
204
+ elif preview_type == "file":
205
+ path = reader.get_artifact_path(art.artifactKey)
206
+ if path:
207
+ f_col1, f_col2 = st.columns([4, 1])
208
+ with f_col1:
209
+ st.code(str(path), language="text")
210
+ with f_col2:
211
+ if path.exists():
212
+ st.download_button(
213
+ "⬇️ Download",
214
+ data=path.read_bytes(),
215
+ file_name=path.name,
216
+ mime=art.mimeType or "application/octet-stream",
217
+ use_container_width=True # Button still uses old kwarg? No, replaced below if needed in logic, but standard download_button uses use_container_width in modern versions. If your version deprecated it for buttons too, this should be width="stretch". Let's stick to consistent modern API.
218
+ )
219
+ else:
220
+ st.warning("File path resolution failed")
221
+
222
+ else:
223
+ st.info("No preview available for this artifact type.")
224
+
225
+ st.divider()
226
+
227
+ # ==========================================
228
+ # 4. Logs & Debug
229
+ # ==========================================
230
+ st.subheader("Logs & System Info")
231
+
232
+ tab1, tab2, tab3, tab4 = st.tabs(["📜 Algorithm Log", "📤 Stdout", "⚠️ Stderr", "🔍 Manifest JSON"])
233
+
234
+ with tab1:
235
+ log = reader.read_algorithm_log()
236
+ if log:
237
+ st.code(log, language="text")
238
+ else:
239
+ st.caption("No algorithm log available.")
240
+
241
+ with tab2:
242
+ stdout = reader.read_stdout()
243
+ if stdout:
244
+ st.code(stdout, language="text")
245
+ else:
246
+ st.caption("No stdout recorded.")
247
+
248
+ with tab3:
249
+ stderr = reader.read_stderr()
250
+ if stderr:
251
+ st.code(stderr, language="text")
252
+ else:
253
+ st.caption("No stderr recorded.")
254
+
255
+ with tab4:
256
+ if reader.has_manifest():
257
+ manifest = reader.read_manifest()
258
+ st.code(json.dumps(manifest.model_dump(exclude_none=True), indent=2), language="json")
259
+ else:
260
+ st.caption("No manifest file.")
261
+
262
+ st.divider()
263
+
264
+ # ==========================================
265
+ # 5. Danger Zone
266
+ # ==========================================
267
+ with st.expander("🗑️ Danger Zone", expanded=False):
268
+ st.markdown("Deleting a run is irreversible. It will remove all artifacts and logs.")
269
+
270
+ confirm_key = f"confirm_del_run_{run.run_id}"
271
+
272
+ if st.button("Delete This Run", key=f"del_btn_{run.run_id}", type="secondary"):
273
+ st.session_state[confirm_key] = True
274
+
275
+ if st.session_state.get(confirm_key):
276
+ st.warning(f"Are you sure you want to delete Run `{run.run_id}`?")
277
+ d_c1, d_c2 = st.columns([1, 1])
278
+ with d_c1:
279
+ # [Modified] use_container_width -> width="stretch"
280
+ if st.button("Yes, Delete", key=f"yes_del_{run.run_id}", type="primary", width="stretch"):
281
+ try:
282
+ run_manager.delete_run(run.run_id)
283
+ st.success(f"Deleted run {run.run_id}")
284
+ st.session_state.pop(confirm_key, None)
285
+ st.rerun()
286
+ except Exception as e:
287
+ st.error(f"Delete failed: {e}")
288
+ with d_c2:
289
+ # [Modified] use_container_width -> width="stretch"
290
+ if st.button("Cancel", key=f"no_del_{run.run_id}", width="stretch"):
291
+ st.session_state.pop(confirm_key, None)
292
+ st.rerun()