Studio: always show the executed Python script in chat with a download option (#7240)

The script the python tool runs was rendered inside a collapsible that closes
when the run ends or the thread is reopened, so the code disappeared from the
transcript and there was no way to save it. Render the script outside the
collapsible so it stays visible, and add a Download button that saves it as
script.py. Other tools and normal chat are unaffected.
This commit is contained in:
Daniel Han 2026-07-23 02:13:51 -07:00 committed by GitHub
commit 5aedfd0b46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -8,7 +8,7 @@ import { getAuthToken } from "@/features/auth/session";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { useToolArgsStatus } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CodeIcon, CopyIcon } from "lucide-react";
import { CodeIcon, CopyIcon, DownloadIcon } from "lucide-react";
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
@ -83,6 +83,41 @@ function CopyBtn({ text }: { text: string }) {
);
}
/** Save the executed script as a .py file via a client-side Blob (no server file serving). */
function DownloadBtn({ code, name = "script.py" }: { code: string; name?: string }) {
const download = useCallback(() => {
if (typeof document === "undefined") {
return;
}
try {
const blob = new Blob([code], { type: "text/x-python" });
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 {
// Best-effort: never break the transcript over a download.
}
}, [code, name]);
return (
<button
type="button"
onClick={download}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Download script"
>
<DownloadIcon className="size-3" />
Download
</button>
);
}
/** Syntax-highlighted code via Streamdown + shiki; inherits parent container. */
function HighlightedCode({ code: source, language }: { code: string; language: string }) {
const markdown = useMemo(
@ -153,23 +188,32 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
const authToken = getAuthToken();
return (
// Open when mounted mid-run so live output shows; collapsed from history.
// Run status and output collapse from history, but the script source is
// rendered outside ToolFallbackContent so it stays visible on reopen (#7165).
<ToolFallbackRoot defaultOpen={isRunning}>
<ToolFallbackTrigger
toolName={firstLine ? `Python: ${firstLine}` : "Python"}
status={status}
icon={CodeIcon}
/>
{code && (
<div className="mt-1 pl-5">
<div className="border-l-2 border-muted-foreground/20 pl-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
script
</span>
<div className="flex items-center gap-1">
<CopyBtn text={code} />
<DownloadBtn code={code} />
</div>
</div>
<HighlightedCode code={code} language="python" />
</div>
</div>
)}
<ToolFallbackContent>
<div className="border-l-2 border-muted-foreground/20 pl-2">
{/* Code + copy */}
{code && (
<div className="flex justify-end">
<CopyBtn text={code} />
</div>
)}
{code && <HighlightedCode code={code} language="python" />}
{/* Output */}
{isRunning ? (
<>