diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx index 6369797c915..4c143f627d9 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx @@ -133,6 +133,9 @@ export const AssistantQueryCell = ({ cancelLabel="Skip" confirmLabel="Run query" confirmLabelLoading="Running..." + successMessage="Query executed" + errorMessage="Failed to execute SQL" + deniedMessage="Skipped query" onCancel={onDeny} onConfirm={onApprove} > diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.test.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.test.tsx new file mode 100644 index 00000000000..79f628e686c --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Confirm } from './Confirm' + +describe('Confirm', () => { + it('keeps its content visible and replaces actions with a success status', () => { + render( + +
Query preview
+
+ ) + + expect(screen.getByText('Query preview')).toBeInTheDocument() + expect(screen.getByText('Query executed')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('keeps its content visible and replaces actions with an error status', () => { + render( + +
Query preview
+
+ ) + + expect(screen.getByText('Query preview')).toBeInTheDocument() + expect(screen.getByText('Failed to execute SQL')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx index a3a7d26c975..fc02c4367de 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx @@ -1,7 +1,8 @@ -import { type PropsWithChildren } from 'react' +import { Check, X } from 'lucide-react' +import { type PropsWithChildren, type ReactNode } from 'react' import { Button, cn } from 'ui' -import { getConfirmFooterBar } from './Confirm.utils' +import { getConfirmFooterBar, type ConfirmFooterApprovalState } from './Confirm.utils' interface ConfirmFooterProps { message: string @@ -10,6 +11,9 @@ interface ConfirmFooterProps { confirmLabelLoading?: string isLoading?: boolean isDisabled?: boolean + outcome?: 'success' | 'error' | 'denied' + showActions?: boolean + action?: ReactNode /** Omit the confirm button so only Skip remains (unparseable / unapplyable previews). */ denyOnly?: boolean /** Escape hatch for consumers that attach the bar directly under their own frame. */ @@ -26,6 +30,9 @@ export const ConfirmFooter = ({ confirmLabelLoading = 'Working...', isLoading = false, isDisabled = false, + outcome, + showActions = true, + action, denyOnly = false, className, onCancel, @@ -41,17 +48,28 @@ export const ConfirmFooter = ({ className )} > -
{message}
-
- - {!denyOnly && ( - - )} +
+ {outcome === 'success' && } + {outcome === 'error' && } + {message}
+ {(showActions || action) && ( +
+ {showActions && ( + <> + + {!denyOnly && ( + + )} + + )} + {action} +
+ )}
) } @@ -61,11 +79,15 @@ interface ConfirmProps { * Result of `getManualToolApprovalConfirmState`. Interactive buttons only for * `approval-requested`; `approval-responded` is the post-approve loading morph. */ - state?: string + state?: ConfirmFooterApprovalState message: string cancelLabel?: string confirmLabel?: string confirmLabelLoading?: string + successMessage?: string + errorMessage?: string + deniedMessage?: string + footerAction?: ReactNode extraLoading?: boolean isLoading?: boolean /** @@ -93,6 +115,10 @@ export const Confirm = ({ cancelLabel = 'Skip', confirmLabel = 'Confirm', confirmLabelLoading = 'Working...', + successMessage, + errorMessage, + deniedMessage, + footerAction, extraLoading = false, isLoading = false, fill = false, @@ -104,6 +130,14 @@ export const Confirm = ({ const bar = getConfirmFooterBar(state) const showLoading = bar.isLoading || extraLoading || isLoading const isApprovalRequested = state === 'approval-requested' + const footerMessage = + bar.outcome === 'success' + ? (successMessage ?? message) + : bar.outcome === 'error' + ? (errorMessage ?? message) + : bar.outcome === 'denied' + ? (deniedMessage ?? message) + : message return (
{bar.show && ( { expect(getConfirmFooterBar('approval-responded')).toEqual({ show: true, isLoading: true }) }) - it('hides the bar for every other tool state', () => { - expect(getConfirmFooterBar('input-available')).toEqual({ show: false, isLoading: false }) - expect(getConfirmFooterBar('output-available')).toEqual({ show: false, isLoading: false }) - expect(getConfirmFooterBar('output-denied')).toEqual({ show: false, isLoading: false }) + it('shows a terminal outcome after a completed approval', () => { + expect(getConfirmFooterBar('success')).toEqual({ + show: true, + isLoading: false, + outcome: 'success', + }) + expect(getConfirmFooterBar('error')).toEqual({ + show: true, + isLoading: false, + outcome: 'error', + }) + expect(getConfirmFooterBar('denied')).toEqual({ + show: true, + isLoading: false, + outcome: 'denied', + }) }) }) @@ -47,6 +59,27 @@ describe('getManualToolApprovalConfirmState', () => { ).toBe('approval-responded') }) + it('keeps a terminal footer after a manual tool completes', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'output-available', + approval: { id: 'approval-1', approved: true }, + }) + ).toBe('success') + expect( + getManualToolApprovalConfirmState({ + state: 'output-error', + approval: { id: 'approval-1', approved: true }, + }) + ).toBe('error') + expect( + getManualToolApprovalConfirmState({ + state: 'output-denied', + approval: { id: 'approval-1', approved: false }, + }) + ).toBe('denied') + }) + it('hides the footer for automatic approvals', () => { expect( getManualToolApprovalConfirmState({ @@ -71,9 +104,10 @@ describe('getManualToolApprovalConfirmState', () => { ).toBeUndefined() }) - it('ignores non-approval tool states', () => { + it('ignores terminal states that were not manually approved', () => { expect(getManualToolApprovalConfirmState({ state: 'input-available' })).toBeUndefined() expect(getManualToolApprovalConfirmState({ state: 'output-available' })).toBeUndefined() + expect(getManualToolApprovalConfirmState({ state: 'output-error' })).toBeUndefined() expect(getManualToolApprovalConfirmState({ state: 'output-denied' })).toBeUndefined() }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts index 9ce728c9294..d6061f4620e 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts @@ -1,4 +1,9 @@ -export type ConfirmFooterApprovalState = 'approval-requested' | 'approval-responded' +export type ConfirmFooterApprovalState = + | 'approval-requested' + | 'approval-responded' + | 'success' + | 'error' + | 'denied' /** Sent with Skip so the model sees a user choice, not the SDK default "Tool execution denied." */ export const USER_SKIPPED_TOOL_REASON = 'The user skipped this action.' @@ -11,20 +16,27 @@ export type ToolApprovalFields = { } /** - * Whether the confirm bar should render, and whether it is in the post-approve loading - * morph. Driven by the AI SDK tool approval state; any other state hides the bar. + * Whether the confirm bar should render, and whether it is loading or in a terminal state. + * Driven by the AI SDK tool approval state; any other state hides the bar. */ -export function getConfirmFooterBar(state?: string): { show: boolean; isLoading: boolean } { +export function getConfirmFooterBar(state?: ConfirmFooterApprovalState): { + show: boolean + isLoading: boolean + outcome?: 'success' | 'error' | 'denied' +} { if (state === 'approval-requested') return { show: true, isLoading: false } if (state === 'approval-responded') return { show: true, isLoading: true } + if (state === 'success' || state === 'error' || state === 'denied') { + return { show: true, isLoading: false, outcome: state } + } return { show: false, isLoading: false } } /** * Maps a tool part onto the confirm footer. Follows the AI SDK `useChat` rule: * interactive Approve/Deny only for `approval-requested` when `!approval.isAutomatic`. - * `approval-responded` keeps a loading morph after a manual approve; denials and - * automatic decisions hide the bar. + * `approval-responded` keeps a loading morph after a manual approve. Completed manual + * approvals stay visible as a terminal outcome; automatic decisions never get a footer. * * @see https://ai-sdk.dev/docs/agents/tool-approvals */ @@ -38,6 +50,9 @@ export function getManualToolApprovalConfirmState({ if (approval?.isAutomatic) return undefined if (state === 'approval-requested') return 'approval-requested' if (state === 'approval-responded' && approval?.approved !== false) return 'approval-responded' + if (state === 'output-available' && approval?.approved === true) return 'success' + if (state === 'output-error' && approval?.approved === true) return 'error' + if (state === 'output-denied' && approval?.approved === false) return 'denied' return undefined } diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx index d22e46c9cd5..5b866559f45 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx @@ -16,6 +16,7 @@ interface EdgeFunctionRendererProps { onDeny?: () => void isDeploying?: boolean initialIsDeployed?: boolean + errorText?: string confirmState?: ConfirmFooterApprovalState } @@ -27,6 +28,7 @@ export const EdgeFunctionRenderer = ({ onDeny, isDeploying = false, initialIsDeployed, + errorText, confirmState, }: EdgeFunctionRendererProps) => { const { ref } = useParams() @@ -85,6 +87,9 @@ export const EdgeFunctionRenderer = ({ cancelLabel="Skip" confirmLabel="Deploy" confirmLabelLoading="Deploying..." + successMessage="Edge Function deployed" + errorMessage="Failed to deploy Edge Function" + deniedMessage="Skipped Edge Function deployment" isLoading={isDeploying} onCancel={onDeny} onConfirm={handleDeploy} @@ -97,6 +102,7 @@ export const EdgeFunctionRenderer = ({ disabled={isConfirming} isDeploying={isDeploying} isDeployed={initialIsDeployed} + errorText={errorText} functionUrl={functionUrl} deploymentDetailsUrl={deploymentDetailsUrl} downloadCommand={downloadCommand} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index b3b4fe25584..821f1d7cbfa 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -110,24 +110,17 @@ function ToolDisplayExecuteSqlLoading({ label = 'Writing SQL...' }: { label?: st ) } -function ToolDisplayExecuteSqlFailure() { - return
Failed to execute SQL.
-} - function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) { const { id } = useMessageInfoContext() const { addToolApprovalResponse } = useMessageActionsContext() - const { toolCallId, state, input, output } = toolPart + const { toolCallId, state, input: submittedInput, output } = toolPart + const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput if (state === 'input-streaming') { return } - if (state === 'output-error') { - return - } - const { data: chart, success } = parseExecuteSqlChartResult(input) if (!success) return null @@ -136,7 +129,8 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) { state === 'approval-requested' || state === 'approval-responded' || state === 'output-denied' || - state === 'output-available' + state === 'output-available' || + state === 'output-error' ) { const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ state, @@ -150,7 +144,11 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) { id={`${id}-${toolCallId}`} sql={chart.sql} title={chart.label} - initialResult={toAssistantQueryResult(output)} + initialResult={ + state === 'output-error' + ? { rows: [], error: { message: toolPart.errorText ?? 'Failed to execute SQL' } } + : toAssistantQueryResult(output) + } view={chart.view} xAxis={chart.xAxis} yAxis={chart.yAxis} @@ -171,10 +169,12 @@ const TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT = new Set([ 'approval-responded', 'output-denied', 'output-available', + 'output-error', ]) function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { - const { state, input, output } = toolPart + const { state, input: submittedInput, output } = toolPart + const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput const { addToolApprovalResponse } = useMessageActionsContext() if (state === 'input-streaming') { @@ -186,10 +186,6 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { ) } - if (state === 'output-error') { - return

Failed to deploy Edge Function.

- } - if (!TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT.has(state)) return null const parsedInput = deployEdgeFunctionInputSchema.safeParse(input) @@ -213,6 +209,7 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { confirmState={confirmState} isDeploying={confirmState === 'approval-responded'} initialIsDeployed={isInitiallyDeployed} + errorText={state === 'output-error' ? toolPart.errorText : undefined} onApprove={onApprove} onDeny={onDeny} /> @@ -224,11 +221,6 @@ const NOTEBOOK_DRAFTING_LABEL: Record = { update: 'Drafting notebook update...', } -const NOTEBOOK_FAILED_LABEL: Record = { - create: 'Failed to create notebook.', - update: 'Failed to update notebook.', -} - function MessagePartNotebookProposal({ toolPart, mode, @@ -236,7 +228,8 @@ function MessagePartNotebookProposal({ toolPart: ToolUIPart mode: NotebookProposalMode }) { - const { state, input, output } = toolPart + const { state, input: submittedInput, output } = toolPart + const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput const { addToolApprovalResponse } = useMessageActionsContext() if (state === 'input-streaming') { @@ -248,10 +241,6 @@ function MessagePartNotebookProposal({ ) } - if (state === 'output-error') { - return

{NOTEBOOK_FAILED_LABEL[mode]}

- } - const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ state, approval: toolPart.approval, diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx index 9c3976f79d4..c3b2277a9ae 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.tsx @@ -10,15 +10,11 @@ import { toQueryLogsResult, } from './MessagePartQueryLogs.utils' -type QueryLogsToolPart = Pick - -function QueryLogsFailure() { - return
Failed to query logs.
-} +type QueryLogsToolPart = Pick export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart }) { const { id } = useMessageInfoContext() - const { toolCallId, state, input, output } = toolPart + const { toolCallId, state, input: submittedInput, output } = toolPart if (state === 'input-streaming' || state === 'input-available') { return ( @@ -29,14 +25,16 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart ) } - if (state === 'output-error') return - if (state !== 'output-available') return null + if (state !== 'output-available' && state !== 'output-error') return null - const parsedInput = parseQueryLogsInput(input) - const result = toQueryLogsResult(output) - if (!parsedInput.success || !result) { - return - } + const parsedInput = parseQueryLogsInput(submittedInput) + if (!parsedInput.success) return null + + const result = + state === 'output-error' + ? { rows: [], error: { message: toolPart.errorText ?? 'Failed to query logs' } } + : toQueryLogsResult(output) + if (!result) return null return (
@@ -52,6 +50,7 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart ), }} initialResult={result} + confirmState={state === 'output-error' ? 'error' : undefined} />
) diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx index df86f4795e2..ac50ecb61d6 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx @@ -151,18 +151,53 @@ describe('NotebookProposalRenderer', () => { expect(onApprove).not.toHaveBeenCalled() }) - it('renders an Open notebook link once output is available', () => { + it('keeps the create preview and marks it successful once output is available', () => { render( ) - const link = screen.getByRole('link', { name: 'Open notebook' }) - expect(link).toHaveAttribute('href', `/project/default/explorer/notebook/${NOTEBOOK_ID}`) + expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() + expect(screen.getByText('Signup funnel')).toBeInTheDocument() + expect(screen.getByText('Notebook created')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open notebook' })).toHaveAttribute( + 'href', + `/project/default/explorer/notebook/${NOTEBOOK_ID}` + ) + }) + + it('keeps the create preview and marks it failed when the tool errors', () => { + render( + + ) + + expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() + expect(screen.getByText('New notebook')).toBeInTheDocument() + expect(screen.getByText('Failed to create notebook')).toBeInTheDocument() }) it('keeps the preview in the message after skip', () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index e59c5d6aa06..b6ccfc94cf9 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { Loader2 } from 'lucide-react' import Link from 'next/link' -import { type PropsWithChildren } from 'react' +import { type PropsWithChildren, type ReactNode } from 'react' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' @@ -25,14 +25,13 @@ import { toWireNotebook } from '@/data/content/notebooks/notebook-schema' export type NotebookProposalMode = 'create' | 'update' -// input-streaming and output-error are handled by the caller before this component is -// rendered — see MessagePartNotebookProposal in Message.Parts.tsx. export type NotebookProposalState = | 'input-available' | 'approval-requested' | 'approval-responded' | 'output-denied' | 'output-available' + | 'output-error' export interface NotebookProposalRendererProps { mode: NotebookProposalMode @@ -45,7 +44,12 @@ export interface NotebookProposalRendererProps { onDeny?: () => void } -type NotebookProposalStepProps = Omit +type NotebookProposalStepProps = Omit< + NotebookProposalRendererProps, + 'mode' | 'output' | 'state' +> & { + footerAction?: ReactNode +} const MODE_COPY = { create: { @@ -69,17 +73,23 @@ const MODE_COPY = { * same way `AssistantQueryCell` wraps `QueryEditor`. */ export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) => { - const { mode, state, output } = props + const { ref } = useParams() + const { mode, state, input, output, confirmState, onApprove, onDeny } = props + const parsedOutput = notebookToolOutputSchema.safeParse(output) + const footerAction = + state === 'output-available' && parsedOutput.success && ref ? ( + + ) : undefined - if (state === 'output-available') { - return - } - - const { input, confirmState, onApprove, onDeny } = props return mode === 'create' ? ( @@ -87,33 +97,13 @@ export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) = ) } -function NotebookOutputSummary({ mode, output }: { mode: NotebookProposalMode; output: unknown }) { - const { ref } = useParams() - const parsedOutput = notebookToolOutputSchema.safeParse(output) - const label = MODE_COPY[mode].outputLabel - - return ( -
- - {parsedOutput.success ? `${label}: ${parsedOutput.data.name}` : label} - - {parsedOutput.success && ref && ( - - )} -
- ) -} - interface NotebookConfirmProps { mode: NotebookProposalMode confirmState?: ConfirmFooterApprovalState @@ -122,6 +112,7 @@ interface NotebookConfirmProps { confirmLabelLoading?: string extraLoading?: boolean denyOnly?: boolean + footerAction?: ReactNode onApprove?: () => void onDeny?: () => void } @@ -135,6 +126,7 @@ function NotebookConfirm({ confirmLabelLoading, extraLoading, denyOnly, + footerAction, onApprove, onDeny, children, @@ -149,6 +141,10 @@ function NotebookConfirm({ cancelLabel="Skip" confirmLabel={confirmLabel ?? copy.confirmLabel} confirmLabelLoading={confirmLabelLoading ?? copy.confirmLabelLoading} + successMessage={copy.outputLabel} + errorMessage={`Failed to ${mode} notebook`} + deniedMessage={`Skipped notebook ${mode === 'create' ? 'creation' : 'update'}`} + footerAction={footerAction} extraLoading={extraLoading} denyOnly={denyOnly} onCancel={onDeny} @@ -188,6 +184,7 @@ function NotebookParseFailure({ function CreateNotebookProposal({ input, confirmState, + footerAction, onApprove, onDeny, }: NotebookProposalStepProps) { @@ -216,6 +213,7 @@ function CreateNotebookProposal({ @@ -227,6 +225,7 @@ function CreateNotebookProposal({ function UpdateNotebookProposal({ input, confirmState, + footerAction, onApprove, onDeny, }: NotebookProposalStepProps) { @@ -308,6 +307,7 @@ function UpdateNotebookProposal({