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 <danielhanchen@gmail.com>
This commit is contained in:
oobabooga 2026-07-28 09:41:36 -03:00 committed by GitHub
commit e3ae08eb80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 52 additions and 27 deletions

View file

@ -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 (

View file

@ -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}</>;
}

View file

@ -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.
<ToolFallbackRoot defaultOpen={isRunning}>
<ToolFallbackTrigger
toolName={firstLine ? `Python: ${firstLine}` : "Python"}
status={status}
icon={CodeIcon}
/>
<ToolFallbackContent>
{code && (
{code && (
<div className="mt-1 pl-5">
<ToolCodeCell
label="script"
code={code}
@ -103,7 +106,9 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
downloadName="script.py"
streaming={isWriting}
/>
)}
</div>
)}
<ToolFallbackContent>
<div className="border-l-2 border-muted-foreground/20 pl-2">
{/* Output */}
{isRunning ? (

View file

@ -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");