From e3ae08eb80abe3f90e69905a1328e8728216a837 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Tue, 28 Jul 2026 09:41:36 -0300 Subject: [PATCH] Studio: keep grouped Python scripts visible and save them natively (#7528) * Studio: keep grouped Python scripts visible and save them natively * Studio: render the executed Python script outside the card collapsible Ungrouping the aggregate tool group was not enough on its own. Each Python card still mounts with defaultOpen={isRunning}, so on a reopened turn the script and its Copy/Download controls stayed hidden behind the card's own chevron and the reported issue persisted. Render ToolCodeCell outside ToolFallbackContent for Python, restoring the behaviour from #7240 that #7455 folded back inside when it unified the code cell. Status, output and images still collapse. Terminal keeps its command inside the collapsible: a one-line command is not the artifact a user reopens a thread to retrieve, a script is. Verified against a running Studio: reopening a persisted turn with two adjacent Python calls now shows both scripts and both Download controls with no clicks, and Download still saves byte-exact script.py. --------- Co-authored-by: Daniel Han --- .../assistant-ui/tool-code-cell.tsx | 27 +++++++------------ .../components/assistant-ui/tool-group.tsx | 12 +++++---- .../assistant-ui/tool-ui-python.tsx | 13 ++++++--- studio/src-tauri/src/native_file_dialogs.rs | 27 ++++++++++++++++++- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/tool-code-cell.tsx b/studio/frontend/src/components/assistant-ui/tool-code-cell.tsx index 83df018af6..6609b8e71b 100644 --- a/studio/frontend/src/components/assistant-ui/tool-code-cell.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-code-cell.tsx @@ -4,6 +4,8 @@ "use client"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { downloadFile, isDownloadCancelled } from "@/lib/native-files"; +import { toast } from "@/lib/toast"; import { code as codePlugin } from "@streamdown/code"; import { CopyIcon, DownloadIcon } from "lucide-react"; import { Tick02Icon } from "@/lib/tick-icon"; @@ -61,24 +63,15 @@ export function CopyBtn({ text }: { text: string }) { } function DownloadBtn({ code, name }: { code: string; name: string }) { + // Route through the shared boundary: browsers keep the normal download, + // Tauri gets the native save chooser. A bare blob anchor is silently + // dropped by the desktop WebView2. const download = useCallback(() => { - if (typeof document === "undefined") { - return; - } - try { - const blob = new Blob([code], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = name; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - // Revoke next tick, after the click consumes the URL. - setTimeout(() => URL.revokeObjectURL(url), 0); - } catch { - // Never break the transcript over a download. - } + void downloadFile(code, name, "text/plain;charset=utf-8").catch((error) => { + if (!isDownloadCancelled(error)) { + toast.error("Could not save file."); + } + }); }, [code, name]); return ( diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx index af370d892e..942bc6a852 100644 --- a/studio/frontend/src/components/assistant-ui/tool-group.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx @@ -215,11 +215,13 @@ const ToolGroupImpl: FC< PropsWithChildren<{ startIndex: number; endIndex: number }> > = ({ children, startIndex, endIndex }) => { const toolCount = endIndex - startIndex + 1; - const containsArtifactTool = useAuiState(({ message }) => + const containsUngroupedTool = useAuiState(({ message }) => message.parts .slice(startIndex, endIndex + 1) .some( - (part) => part.type === "tool-call" && part.toolName === "render_html", + (part) => + part.type === "tool-call" && + (part.toolName === "render_html" || part.toolName === "python"), ), ); // A blocking allow/deny prompt must never be hidden inside a collapsed @@ -271,9 +273,9 @@ const ToolGroupImpl: FC< (hasLiveOutput && messageRunning) || (forcedOpenRef.current && messageRunning); - // Render single tool calls and canvases directly so cards never hide in a - // collapsed group. - if (toolCount <= 1 || containsArtifactTool) { + // Render single calls, canvases, and Python scripts directly so their + // persistent content never hides in a collapsed group. + if (toolCount <= 1 || containsUngroupedTool) { return <>{children}; } diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index e058a04ed1..bf7a1cceb3 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -87,15 +87,18 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({ const isWriting = isWritingCode && !awaitingApproval; return ( - // Script, status and output all collapse behind the one chevron. + // Status, output and images collapse from history; the executed script + // renders outside ToolFallbackContent so it stays visible on reopen + // (#7165). Terminal keeps its command inside the collapsible -- a one-line + // command is not the artifact a user comes back for, a script is. - - {code && ( + {code && ( +
- )} +
+ )} +
{/* Output */} {isRunning ? ( diff --git a/studio/src-tauri/src/native_file_dialogs.rs b/studio/src-tauri/src/native_file_dialogs.rs index b2635e66d3..0b46f81f49 100644 --- a/studio/src-tauri/src/native_file_dialogs.rs +++ b/studio/src-tauri/src/native_file_dialogs.rs @@ -47,11 +47,14 @@ fn save_filter(file_name: &str) -> (&'static str, Vec<&'static str>) { Some("csv") => ("CSV", vec!["csv"]), Some("md") | Some("markdown") => ("Markdown", vec!["md", "markdown"]), Some("html") | Some("htm") => ("HTML", vec!["html", "htm"]), + Some("py") => ("Python", vec!["py"]), + Some("sh") => ("Shell script", vec!["sh"]), Some("zip") => ("ZIP archive", vec!["zip"]), _ => ( "Export files", vec![ - "json", "jsonl", "ndjson", "csv", "md", "markdown", "html", "htm", "zip", + "json", "jsonl", "ndjson", "csv", "md", "markdown", "html", "htm", "py", "sh", + "zip", ], ), } @@ -261,6 +264,28 @@ mod tests { assert_eq!(save_filter("canvas.HTM"), ("HTML", vec!["html", "htm"])); } + #[test] + fn python_scripts_use_a_python_save_filter() { + assert_eq!(save_filter("script.py"), ("Python", vec!["py"])); + assert_eq!(save_filter("script.PY"), ("Python", vec!["py"])); + } + + #[test] + fn shell_commands_use_a_shell_save_filter() { + // The terminal card downloads command.sh through the same cell. + assert_eq!(save_filter("command.sh"), ("Shell script", vec!["sh"])); + assert_eq!(save_filter("command.SH"), ("Shell script", vec!["sh"])); + } + + #[test] + fn generic_fallback_covers_every_tool_download_name() { + let (name, extensions) = save_filter("no-extension"); + assert_eq!(name, "Export files"); + for wanted in ["py", "sh", "json", "jsonl", "csv", "md", "html", "zip"] { + assert!(extensions.contains(&wanted), "fallback lost {wanted}"); + } + } + #[test] fn reads_supported_import_and_rejects_other_extensions() { let jsonl_path = temp_path("allowed").with_extension("JSONL");