diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index 46178ae1ac3..b7ee45c949d 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -18,6 +18,7 @@ import { import { MessageMarkdown } from './MessageMarkdown' import { MessagePartQueryLogs } from './MessagePartQueryLogs' import { NotebookProposalRenderer, type NotebookProposalMode } from './NotebookProposalRenderer' +import { NotebookRunRenderer } from './NotebookRunRenderer' import { parseSupportRequestMessage, SupportRequestMessage } from './SupportRequestMessage' function MessagePartText({ textPart }: { textPart: TextUIPart }) { @@ -261,6 +262,32 @@ function MessagePartNotebookProposal({ ) } +function MessagePartNotebookRun({ toolPart }: { toolPart: ToolUIPart }) { + const { state, input: submittedInput, output } = toolPart + const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput + const { addToolApprovalResponse } = useMessageActionsContext() + + if (state === 'input-streaming') + return + + const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ + state, + approval: toolPart.approval, + addToolApprovalResponse, + }) + + return ( + + ) +} + const MessagePart = { Text: MessagePartText, Dynamic: MessagePartDynamicTool, @@ -270,6 +297,7 @@ const MessagePart = { QueryLogs: MessagePartQueryLogs, DeployEdgeFunction: MessagePartDeployEdgeFunction, NotebookProposal: MessagePartNotebookProposal, + NotebookRun: MessagePartNotebookRun, } as const function MessagePartContainer({ @@ -287,6 +315,7 @@ const isWideMessagePart = (part: NonNullable[number]) => part.type === 'tool-query_logs' || part.type === 'tool-create_notebook' || part.type === 'tool-update_notebook' || + part.type === 'tool-run_notebook' || (part.type === 'dynamic-tool' && part.toolName === 'query_logs') || // Unlabelled code fences resolve to SQL in MessageMarkdown, too. (part.type === 'text' && /```(?:sql)?(?:\s|$)/i.test(part.text)) @@ -338,6 +367,9 @@ export function MessagePartSwitcher({ case 'tool-update_notebook': { return } + case 'tool-run_notebook': { + return + } case 'source-url': case 'source-document': diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts index 1f901c2ccfe..d7264ec77cc 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts @@ -141,6 +141,11 @@ export const updateNotebookInputSchema = z.object({ operations: notebookOperationsSchema, }) +export const runNotebookInputSchema = z.object({ + id: z.string(), + expected_updated_at: z.string(), +}) + export const notebookToolOutputSchema = z.object({ id: z.string(), name: z.string() }) export const updateNotebookToolOutputSchema = notebookToolOutputSchema.extend({ diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookRunRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookRunRenderer.test.tsx new file mode 100644 index 00000000000..b6745c632b7 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookRunRenderer.test.tsx @@ -0,0 +1,211 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { describe, expect, it, vi } from 'vitest' + +import { NotebookRunRenderer } from './NotebookRunRenderer' +import type { components } from '@/data/api' +import { customRender as render } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +const NOTEBOOK_ID = 'd3aadd77-7c3c-4de7-aa5c-5aa8ac270b44' +const UPDATED_AT = '2026-01-01T00:00:00.000Z' + +const mockNotebook = (updatedAt = UPDATED_AT) => + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/content/item/:id', + response: () => + HttpResponse.json({ + id: NOTEBOOK_ID, + type: 'notebook', + name: 'Signup funnel', + description: '', + favorite: false, + folder_id: null, + inserted_at: UPDATED_AT, + updated_at: updatedAt, + visibility: 'project', + owner_id: 1, + project_id: 1, + content: { + schema_version: 1, + cells: [ + { + _tag: 'database_cell', + _id: 'cell-1', + title: 'Recent signups', + sql: 'select email from auth.users', + row_limit: 100, + }, + ], + }, + }), + }) + +const mockNotebookError = () => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + id: 1, + ref: 'default', + organization_id: 1, + name: 'Test Project', + status: 'ACTIVE_HEALTHY', + cloud_provider: 'AWS', + region: 'us-east-1', + db_host: 'db.default.supabase.co', + restUrl: 'https://default.supabase.co/rest/v1/', + inserted_at: UPDATED_AT, + updated_at: UPDATED_AT, + subscription_id: 'sub-1', + is_branch_enabled: false, + is_physical_backups_enabled: false, + high_availability: false, + integration_source: null, + connectionString: 'postgresql://postgres@localhost:5432/postgres', + is_hibernating: false, + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/content/item/:id', + response: () => + HttpResponse.json({ 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 && ( +
+ +
+ )} + +
+ + ) +}