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:
parent
8746b13e76
commit
e3ae08eb80
4 changed files with 52 additions and 27 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue