({ message: 'Notebook unavailable' }, { status: 500 }),
+ })
+}
+
+describe('NotebookRunRenderer', () => {
+ it('previews the notebook and requests one Run notebook approval', async () => {
+ const user = userEvent.setup()
+ const onApprove = vi.fn()
+ mockNotebook()
+
+ const { container } = render(
+
+ )
+
+ const loadingStatus = container.querySelector('[aria-live="polite"]')
+ expect(loadingStatus).toHaveTextContent('Loading notebook...')
+ expect(loadingStatus?.querySelector('svg')).toHaveClass('motion-reduce:animate-none')
+ expect(await screen.findByText('Assistant wants to run "Signup funnel"')).toBeInTheDocument()
+ expect(container.querySelector('[aria-live="polite"]')).toBe(loadingStatus)
+ expect(loadingStatus).toHaveClass('sr-only')
+ expect(screen.getByText('1 cell')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Run notebook' }))
+ expect(onApprove).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps raw results visible to the user after execution', async () => {
+ mockNotebook()
+
+ render(
+
+ )
+
+ expect(await screen.findByText('Notebook executed')).toBeInTheDocument()
+ expect(screen.getByText('1 row')).toBeInTheDocument()
+ expect(screen.getByText(/person@example\.com/)).toBeInTheDocument()
+ })
+
+ it('warns before approval when the notebook changed since the Assistant read it', async () => {
+ mockNotebook('2026-01-02T00:00:00.000Z')
+
+ render(
+
+ )
+
+ expect(
+ await screen.findByText('Notebook changed since the Assistant read it')
+ ).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Run notebook' })).toBeInTheDocument()
+ })
+
+ it('keeps a failed approval request denyable without allowing the run', async () => {
+ const user = userEvent.setup()
+ const onApprove = vi.fn()
+ const onDeny = vi.fn()
+ mockNotebookError()
+
+ const { container } = render(
+
+ )
+
+ expect(await screen.findByText('Failed to load notebook')).toBeInTheDocument()
+ expect(container.querySelector('[data-slot="assistant-confirm"]')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Run notebook' })).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Skip' }))
+ expect(onDeny).toHaveBeenCalledTimes(1)
+ expect(onApprove).not.toHaveBeenCalled()
+ })
+
+ it('warns when historical results are shown with a newer notebook', async () => {
+ mockNotebook('2026-01-02T00:00:00.000Z')
+
+ render(
+
+ )
+
+ expect(await screen.findByText('Notebook changed since this run')).toBeInTheDocument()
+ expect(screen.getByText(/preview shows the current notebook/i)).toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookRunRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookRunRenderer.tsx
new file mode 100644
index 00000000000..cc4d893ff1b
--- /dev/null
+++ b/apps/studio/components/ui/AIAssistantPanel/NotebookRunRenderer.tsx
@@ -0,0 +1,202 @@
+import { useParams } from 'common'
+import { Loader2 } from 'lucide-react'
+import { Button, cn } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+import { CodeBlock } from 'ui-patterns/CodeBlock'
+
+import { AssistantNotebookPreview } from './AssistantNotebookPreview'
+import { toAssistantQueryResult } from './AssistantQueryCell.utils'
+import { Confirm } from './Confirm'
+import type { ConfirmFooterApprovalState } from './Confirm.utils'
+import { runNotebookInputSchema } from './Message.utils'
+import { AlertError } from '@/components/ui/AlertError'
+import { useNotebookQuery } from '@/data/content/notebooks/notebook-query'
+import { toWireNotebook } from '@/data/content/notebooks/notebook-schema'
+import { notebookRunOutputSchema } from '@/lib/ai/tools/notebook-run-output'
+
+export type NotebookRunState =
+ | 'input-available'
+ | 'approval-requested'
+ | 'approval-responded'
+ | 'output-denied'
+ | 'output-available'
+ | 'output-error'
+
+export interface NotebookRunRendererProps {
+ state: NotebookRunState
+ input: unknown
+ output: unknown
+ confirmState?: ConfirmFooterApprovalState
+ onApprove?: () => void
+ onDeny?: () => void
+}
+
+export const NotebookRunRenderer = ({
+ state,
+ input,
+ output,
+ confirmState,
+ onApprove,
+ onDeny,
+}: NotebookRunRendererProps) => {
+ const { ref } = useParams()
+ const parsedInput = runNotebookInputSchema.safeParse(input)
+ const {
+ data: notebook,
+ isLoading,
+ isError,
+ error,
+ } = useNotebookQuery(
+ { projectRef: ref, id: parsedInput.success ? parsedInput.data.id : undefined },
+ { enabled: parsedInput.success }
+ )
+
+ if (!parsedInput.success) {
+ return (
+
+
+
+ )
+ }
+
+ const loadingStatus = (
+
+ {isLoading && (
+ <>
+
+ Loading notebook...
+ >
+ )}
+
+ )
+
+ if (isLoading) {
+ return <>{loadingStatus}>
+ }
+
+ if (isError || !notebook) {
+ const loadError =
+
+ if (confirmState === 'approval-requested') {
+ return (
+ <>
+ {loadingStatus}
+
+ {loadError}
+
+ >
+ )
+ }
+
+ return (
+ <>
+ {loadingStatus}
+
+ {loadError}
+ {confirmState !== undefined && (
+
+ )}
+
+ >
+ )
+ }
+
+ const entries = toWireNotebook(notebook.content).cells.map((cell) => ({
+ _tag: 'unchanged' as const,
+ cell,
+ }))
+ const parsedOutput = notebookRunOutputSchema.safeParse(output)
+ const isHistoricalRun = state === 'output-available' || state === 'output-error'
+ const referencedUpdatedAt =
+ state === 'output-available' && parsedOutput.success
+ ? parsedOutput.data.updated_at
+ : parsedInput.data.expected_updated_at
+ const hasNotebookChanged = notebook.updated_at !== referencedUpdatedAt
+ const results = parsedOutput.success
+ ? Object.fromEntries(
+ parsedOutput.data.cells.map((cell) => [
+ cell.cell_id,
+ cell.status === 'error'
+ ? { error: { message: cell.error?.message ?? 'Failed to run query' } }
+ : (toAssistantQueryResult(cell.rows ?? []) ?? { rows: [] }),
+ ])
+ )
+ : undefined
+
+ return (
+ <>
+ {loadingStatus}
+
+ {hasNotebookChanged && (
+
+ )}
+
+
+ >
+ )
+}