From bd76d7fc3460dd6a5665e0d04432ebb00f24235e Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 20 Aug 2026 11:35:34 +1000 Subject: [PATCH] feat(studio): wrap assistant Edge Function approval in a Confirm card (#49168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature / UI refactor. ## What is the current behavior? Assistant Edge Function approval nests `ConfirmFooter` under the function block. `addToolApprovalResponse` is wired whenever state is `approval-requested`, including automatic approvals. ## What is the new behavior? Introduces a `Confirm` card that owns the frame, with the footer attached below the body. Edge Function approval uses that card. Interactive Approve/Deny only runs for manual `approval-requested` parts (`!approval.isAutomatic`), matching the [AI SDK tool-approvals `useChat` guidelines](https://ai-sdk.dev/docs/agents/tool-approvals). SQL still uses `DisplayBlockRenderer` until #49170. `ConfirmFooter` is inlined into `Confirm` so SQL can keep importing the named footer until that PR. ## Additional context Part of stack #49171. Base: `chore/ai-sdk-7` (#49167). Notebook proposal Confirm wrapping is **not** in this stack — that file lives on [#49159](https://github.com/supabase/supabase/pull/49159). Follow up after that stack merges. ## Test plan - [ ] Deploy-edge-function tool part shows Confirm with Skip / Deploy - [ ] Existing-function replace warning still requires the second confirm - [ ] After approve, footer morphs to loading and buttons disable - [ ] `Confirm.utils.test.ts` and `EdgeFunctionRenderer.test.tsx` pass ## Summary by CodeRabbit * **New Features** * Added confirmation cards for AI-assisted actions, including approve and cancel controls. * Improved handling of manual approval requests for SQL execution, notebook changes, and Edge Function deployment. * Added support for customizing report and Edge Function block styling. * **Bug Fixes** * Automatic approvals no longer appear as pending manual confirmations. * Skipped SQL actions now provide clearer messaging. * **Tests** * Expanded coverage for approval states, confirmation controls, and automatic decisions. --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 5 --- .../ReportBlock/ReportBlockContainer.tsx | 7 +- .../AIAssistant.utils.test.ts | 12 ++ .../ui/AIAssistantPanel/AIAssistant.utils.ts | 3 +- .../ui/AIAssistantPanel/AssistantChat.tsx | 2 +- .../ui/AIAssistantPanel/Confirm.tsx | 126 +++++++++++++++++ .../ui/AIAssistantPanel/Confirm.utils.test.ts | 132 ++++++++++++++++++ .../ui/AIAssistantPanel/Confirm.utils.ts | 86 ++++++++++++ .../ui/AIAssistantPanel/ConfirmFooter.tsx | 42 ------ .../AIAssistantPanel/DisplayBlockRenderer.tsx | 2 +- .../EdgeFunctionRenderer.test.tsx | 18 ++- .../AIAssistantPanel/EdgeFunctionRenderer.tsx | 45 +++--- .../ui/AIAssistantPanel/Message.Parts.tsx | 28 ++-- .../NotebookProposalRenderer.tsx | 9 +- .../EdgeFunctionBlock/EdgeFunctionBlock.tsx | 3 + apps/studio/lib/ai/message-utils.test.ts | 56 ++++++-- apps/studio/lib/ai/message-utils.ts | 36 ++++- apps/studio/lib/ai/prompts.ts | 2 +- 17 files changed, 507 insertions(+), 102 deletions(-) create mode 100644 apps/studio/components/ui/AIAssistantPanel/Confirm.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts delete mode 100644 apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx index 44e7ed8722c..d14e2862687 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlockContainer.tsx @@ -11,6 +11,7 @@ interface ReportBlockContainerProps { draggable?: boolean showDragHandle?: boolean tooltip?: ReactNode + className?: string onDragStart?: (e: DragEvent) => void } @@ -23,6 +24,7 @@ export const ReportBlockContainer = ({ draggable = false, showDragHandle = false, tooltip, + className, onDragStart, children, }: PropsWithChildren) => { @@ -35,7 +37,10 @@ export const ReportBlockContainer = ({ draggable={draggable} unselectable={draggable ? 'on' : undefined} onDragStart={onDragStart} - className="h-full flex flex-col overflow-hidden bg-surface-100 border-overlay relative rounded-sm border shadow-xs" + className={cn( + 'h-full flex flex-col overflow-hidden bg-surface-100 border-overlay relative rounded-sm border shadow-xs', + className + )} > diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts index 2f28dda6bdb..e7c9eaa7d25 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts @@ -127,6 +127,18 @@ describe('AIAssistant.utils.ts:hasPendingToolApproval', () => { expect(hasPendingToolApproval(messages)).toBe(true) }) + + test('Should ignore automatic approvals', () => { + const messages = createMessageWithPart({ + type: 'tool-execute_sql', + toolCallId: 'call-1', + state: 'approval-requested', + input: { sql: 'select 1', label: 'Test query' }, + approval: { id: 'approval-1', isAutomatic: true }, + } as UIMessage['parts'][number]) + + expect(hasPendingToolApproval(messages)).toBe(false) + }) }) describe('AIAssistant.utils.ts:resolvePendingToolApprovalsAsDenied', () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index 92d4b1d694e..a3cb9b63a3f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -15,6 +15,7 @@ import { databaseKeys } from '@/data/database/keys' import { enumeratedTypesKeys } from '@/data/enumerated-types/keys' import { handleError } from '@/data/fetchers' import { tableKeys } from '@/data/tables/keys' +import { isManualApprovalRequested } from '@/lib/ai/message-utils' import { tryParseJson } from '@/lib/helpers' import type { SqlSnippet } from '@/state/ai-assistant-state' import { ResponseError } from '@/types' @@ -85,7 +86,7 @@ export const hasPendingToolApproval = (messages: Pick { if (message.role !== 'assistant') return false - return message.parts?.some((part) => isToolUIPart(part) && part.state === 'approval-requested') + return message.parts?.some((part) => isManualApprovalRequested(part)) }) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx index 5bdb8363df2..25d7f4cad6e 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx @@ -194,7 +194,7 @@ export const AssistantChat = ({ addToolApprovalResponse, stop, regenerate, - } = useChat({ + } = useChat({ id: chatId, ...(chatInstance ? { chat: chatInstance } : {}), sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx new file mode 100644 index 00000000000..8282bdce2ae --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx @@ -0,0 +1,126 @@ +import { type PropsWithChildren } from 'react' +import { Button, cn } from 'ui' + +import { getConfirmFooterBar } from './Confirm.utils' + +interface ConfirmFooterProps { + message: string + cancelLabel?: string + confirmLabel?: string + confirmLabelLoading?: string + isLoading?: boolean + isDisabled?: boolean + /** Escape hatch for consumers that attach the bar directly under their own frame. */ + className?: string + onCancel?: () => void | Promise + onConfirm?: () => void | Promise +} + +/** Action bar that sits at the bottom of `Confirm`. */ +export const ConfirmFooter = ({ + message, + cancelLabel = 'Cancel', + confirmLabel = 'Confirm', + confirmLabelLoading = 'Working...', + isLoading = false, + isDisabled = false, + className, + onCancel, + onConfirm, +}: ConfirmFooterProps) => { + const isInactive = isLoading || isDisabled + + return ( +
+
{message}
+
+ + +
+
+ ) +} + +interface ConfirmProps { + /** + * Result of `getManualToolApprovalConfirmState`. Interactive buttons only for + * `approval-requested`; `approval-responded` is the post-approve loading morph. + */ + state?: string + message: string + cancelLabel?: string + confirmLabel?: string + confirmLabelLoading?: string + extraLoading?: boolean + isLoading?: boolean + /** + * Children fill the remaining height of the card (e.g. `QueryEditor` in viewport + * mode). Omit for content-sized bodies like notebook previews. + */ + fill?: boolean + className?: string + onCancel?: () => void | Promise + onConfirm?: () => void | Promise +} + +/** + * Card that wraps an assistant tool preview and optionally attaches a confirm footer + * below it. The card owns the frame; nested surfaces (QueryEditor viewport, unframed + * edge-function blocks) fill the body. + */ +export const Confirm = ({ + children, + state, + message, + cancelLabel = 'Skip', + confirmLabel = 'Confirm', + confirmLabelLoading = 'Working...', + extraLoading = false, + isLoading = false, + fill = false, + className, + onCancel, + onConfirm, +}: PropsWithChildren) => { + const bar = getConfirmFooterBar(state) + const showLoading = bar.isLoading || extraLoading || isLoading + const isApprovalRequested = state === 'approval-requested' + + return ( +
+
+ {children} +
+ {bar.show && ( + + )} +
+ ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts new file mode 100644 index 00000000000..933e5c0c64c --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + getConfirmFooterBar, + getManualToolApprovalConfirmState, + getManualToolApprovalHandlers, + getManualToolApprovalId, + USER_SKIPPED_TOOL_REASON, +} from './Confirm.utils' + +describe('getConfirmFooterBar', () => { + it('hides the bar when no approval state is provided', () => { + expect(getConfirmFooterBar()).toEqual({ show: false, isLoading: false }) + }) + + it('shows the bar while approval is requested', () => { + expect(getConfirmFooterBar('approval-requested')).toEqual({ show: true, isLoading: false }) + }) + + it('shows a loading bar after the user approves', () => { + 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 }) + }) +}) + +describe('getManualToolApprovalConfirmState', () => { + it('shows an interactive footer for a manual approval request', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-requested', + approval: { id: 'approval-1' }, + }) + ).toBe('approval-requested') + }) + + it('keeps a loading footer after a manual approve', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: true }, + }) + ).toBe('approval-responded') + }) + + it('hides the footer for automatic approvals', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-requested', + approval: { id: 'approval-1', isAutomatic: true }, + }) + ).toBeUndefined() + expect( + getManualToolApprovalConfirmState({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: true, isAutomatic: true }, + }) + ).toBeUndefined() + }) + + it('hides the footer when the user denied the request', () => { + expect( + getManualToolApprovalConfirmState({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: false }, + }) + ).toBeUndefined() + }) + + it('ignores non-approval tool states', () => { + expect(getManualToolApprovalConfirmState({ state: 'input-available' })).toBeUndefined() + expect(getManualToolApprovalConfirmState({ state: 'output-available' })).toBeUndefined() + expect(getManualToolApprovalConfirmState({ state: 'output-denied' })).toBeUndefined() + }) +}) + +describe('getManualToolApprovalId', () => { + it('returns the approval id only for a manual approval-requested part', () => { + expect( + getManualToolApprovalId({ state: 'approval-requested', approval: { id: 'approval-1' } }) + ).toBe('approval-1') + expect( + getManualToolApprovalId({ + state: 'approval-requested', + approval: { id: 'approval-1', isAutomatic: true }, + }) + ).toBeUndefined() + expect( + getManualToolApprovalId({ + state: 'approval-responded', + approval: { id: 'approval-1', approved: true }, + }) + ).toBeUndefined() + }) +}) + +describe('getManualToolApprovalHandlers', () => { + it('wires approve and deny only while a manual approval is requested', () => { + const addToolApprovalResponse = vi.fn() + const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ + state: 'approval-requested', + approval: { id: 'approval-1' }, + addToolApprovalResponse, + }) + + expect(confirmState).toBe('approval-requested') + onApprove?.() + onDeny?.() + expect(addToolApprovalResponse).toHaveBeenCalledWith({ id: 'approval-1', approved: true }) + expect(addToolApprovalResponse).toHaveBeenCalledWith({ + id: 'approval-1', + approved: false, + reason: USER_SKIPPED_TOOL_REASON, + }) + }) + + it('does not call addToolApprovalResponse for automatic approvals', () => { + const addToolApprovalResponse = vi.fn() + const handlers = getManualToolApprovalHandlers({ + state: 'approval-requested', + approval: { id: 'approval-1', isAutomatic: true }, + addToolApprovalResponse, + }) + + expect(handlers).toEqual({ confirmState: undefined }) + expect(addToolApprovalResponse).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts new file mode 100644 index 00000000000..9ce728c9294 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.utils.ts @@ -0,0 +1,86 @@ +export type ConfirmFooterApprovalState = 'approval-requested' | 'approval-responded' + +/** 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.' + +export type ToolApprovalFields = { + id?: string + approved?: boolean + /** AI SDK v7: automatic policy decisions must not get a confirm footer or response. */ + isAutomatic?: boolean +} + +/** + * 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. + */ +export function getConfirmFooterBar(state?: string): { show: boolean; isLoading: boolean } { + if (state === 'approval-requested') return { show: true, isLoading: false } + if (state === 'approval-responded') return { show: true, isLoading: true } + 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. + * + * @see https://ai-sdk.dev/docs/agents/tool-approvals + */ +export function getManualToolApprovalConfirmState({ + state, + approval, +}: { + state: string + approval?: ToolApprovalFields +}): ConfirmFooterApprovalState | undefined { + if (approval?.isAutomatic) return undefined + if (state === 'approval-requested') return 'approval-requested' + if (state === 'approval-responded' && approval?.approved !== false) return 'approval-responded' + return undefined +} + +export function getManualToolApprovalId({ + state, + approval, +}: { + state: string + approval?: ToolApprovalFields +}): string | undefined { + if (state !== 'approval-requested' || approval?.isAutomatic) return undefined + return approval?.id +} + +export function getManualToolApprovalHandlers({ + state, + approval, + addToolApprovalResponse, +}: { + state: string + approval?: ToolApprovalFields + addToolApprovalResponse?: (args: { + id: string + approved: boolean + reason?: string + }) => void | PromiseLike +}): { + confirmState?: ConfirmFooterApprovalState + onApprove?: () => void + onDeny?: () => void +} { + const confirmState = getManualToolApprovalConfirmState({ state, approval }) + const approvalId = getManualToolApprovalId({ state, approval }) + if (!approvalId) return { confirmState } + + return { + confirmState, + onApprove: () => addToolApprovalResponse?.({ id: approvalId, approved: true }), + onDeny: () => + addToolApprovalResponse?.({ + id: approvalId, + approved: false, + reason: USER_SKIPPED_TOOL_REASON, + }), + } +} diff --git a/apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx b/apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx deleted file mode 100644 index cd0c61966ee..00000000000 --- a/apps/studio/components/ui/AIAssistantPanel/ConfirmFooter.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { PropsWithChildren } from 'react' -import { Button, cn } from 'ui' - -interface ConfirmFooterProps { - message: string - cancelLabel?: string - confirmLabel?: string - confirmLabelLoading?: string - isLoading?: boolean - onCancel?: () => void | Promise - onConfirm?: () => void | Promise -} - -export const ConfirmFooter = ({ - message, - cancelLabel = 'Cancel', - confirmLabel = 'Confirm', - confirmLabelLoading = 'Working...', - isLoading = false, - onCancel, - onConfirm, -}: PropsWithChildren) => { - return ( -
-
{message}
-
- - -
-
- ) -} diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx index 908b947dc09..bd2eb162070 100644 --- a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx @@ -8,7 +8,7 @@ import { useRef, useState, type DragEvent, type PropsWithChildren } from 'react' import { DEFAULT_CHART_CONFIG, QueryBlock } from '../QueryBlock/QueryBlock' import { identifyQueryType } from './AIAssistant.utils' -import { ConfirmFooter } from './ConfirmFooter' +import { ConfirmFooter } from './Confirm' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { entityTypeKeys } from '@/data/entity-types/keys' import { lintKeys } from '@/data/lint/keys' diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx index 0d22406570b..bae71ec86c3 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx @@ -1,5 +1,6 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { type ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { EdgeFunctionRenderer } from './EdgeFunctionRenderer' @@ -70,17 +71,22 @@ vi.mock('../EdgeFunctionBlock/EdgeFunctionBlock', () => ({ ), })) -vi.mock('./ConfirmFooter', () => ({ - ConfirmFooter: ({ +vi.mock('./Confirm', () => ({ + Confirm: ({ + children, confirmLabel, onConfirm, }: { + children?: ReactNode confirmLabel?: string onConfirm?: () => void }) => ( - +
+ {children} + +
), })) @@ -104,6 +110,7 @@ describe('EdgeFunctionRenderer', () => { label="Deploy Edge Function" code="Deno.serve(() => new Response('ok'))" functionName="hello-world" + confirmState="approval-requested" onApprove={onApprove} /> ) @@ -132,6 +139,7 @@ describe('EdgeFunctionRenderer', () => { label="Deploy Edge Function" code="Deno.serve(() => new Response('ok'))" functionName="hello-world" + confirmState="approval-requested" onApprove={onApprove} /> ) diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx index bb5c4079511..d22e46c9cd5 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.tsx @@ -1,8 +1,9 @@ import { useParams } from 'common' -import { useMemo, useState, type PropsWithChildren } from 'react' +import { useMemo, useState } from 'react' import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' -import { ConfirmFooter } from './ConfirmFooter' +import { Confirm } from './Confirm' +import { type ConfirmFooterApprovalState } from './Confirm.utils' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' import { useTrack } from '@/lib/telemetry/track' @@ -15,7 +16,7 @@ interface EdgeFunctionRendererProps { onDeny?: () => void isDeploying?: boolean initialIsDeployed?: boolean - showConfirmFooter?: boolean + confirmState?: ConfirmFooterApprovalState } export const EdgeFunctionRenderer = ({ @@ -26,8 +27,8 @@ export const EdgeFunctionRenderer = ({ onDeny, isDeploying = false, initialIsDeployed, - showConfirmFooter = true, -}: PropsWithChildren) => { + confirmState, +}: EdgeFunctionRendererProps) => { const { ref } = useParams() const track = useTrack() const [showReplaceWarning, setShowReplaceWarning] = useState(false) @@ -74,36 +75,36 @@ export const EdgeFunctionRenderer = ({ approveDeploy() } + const isConfirming = confirmState !== undefined + return ( -
+ setShowReplaceWarning(false)} onConfirmReplace={approveDeploy} /> - {showConfirmFooter && ( -
- onDeny?.()} - onConfirm={handleDeploy} - /> -
- )} -
+ ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index fc3132f9b5e..d53bb3e099d 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -3,6 +3,7 @@ import { type DynamicToolUIPart, type ReasoningUIPart, type TextUIPart, type Too import { BrainIcon, CheckIcon, Loader2 } from 'lucide-react' import { cn } from 'ui' +import { getManualToolApprovalHandlers, USER_SKIPPED_TOOL_REASON } from './Confirm.utils' import { DisplayBlockRenderer } from './DisplayBlockRenderer' import { EdgeFunctionRenderer } from './EdgeFunctionRenderer' import { Tool } from './elements/Tool' @@ -166,7 +167,12 @@ function MessagePartExecuteSql({ } onDeny={ approvalId - ? () => addToolApprovalResponse?.({ id: approvalId, approved: false }) + ? () => + addToolApprovalResponse?.({ + id: approvalId, + approved: false, + reason: USER_SKIPPED_TOOL_REASON, + }) : undefined } /> @@ -211,24 +217,22 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) { const isInitiallyDeployed = state === 'output-available' && parsedOutput.success && parsedOutput.data.success === true - const approvalId = state === 'approval-requested' ? toolPart.approval?.id : undefined + const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ + state, + approval: toolPart.approval, + addToolApprovalResponse, + }) return ( addToolApprovalResponse?.({ id: approvalId, approved: true }) : undefined - } - onDeny={ - approvalId - ? () => addToolApprovalResponse?.({ id: approvalId, approved: false }) - : undefined - } + onApprove={onApprove} + onDeny={onDeny} /> ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index 18a38634a89..9a9ea604551 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -5,7 +5,7 @@ import { Button, cn } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' -import { ConfirmFooter } from './ConfirmFooter' +import { ConfirmFooter } from './Confirm' import { createNotebookInputSchema, notebookToolOutputSchema, @@ -119,10 +119,12 @@ interface NotebookConfirmFooterProps { } /** - * `ConfirmFooter` is built to sit flush under the block it confirms (`border-t-0 rounded-b-lg`), - * so that block has to square off its own bottom corners and the two must not be gapped apart. + * `ConfirmFooter` now ships bare so the `Confirm` card can own the frame, so this renderer + * supplies its own flush-under-block border. The block above still has to square off its + * bottom corners and the two must not be gapped apart. */ const GLUED_TO_FOOTER = 'rounded-b-none' +const FLUSH_UNDER_BLOCK = 'border border-t-0 rounded-b-lg' function hasConfirmFooter(state: NotebookProposalState) { return state === 'approval-requested' || state === 'approval-responded' @@ -146,6 +148,7 @@ function NotebookConfirmFooter({ return ( void /** Handler for triggering a deploy */ onDeploy?: () => void + className?: string } export const EdgeFunctionBlock = ({ @@ -70,6 +71,7 @@ export const EdgeFunctionBlock = ({ onDeploy, draggable = false, onDragStart, + className, }: EdgeFunctionBlockProps) => { const resolvedFunctionUrl = functionUrl ?? 'Function URL will be available after deployment' const resolvedDownloadCommand = downloadCommand ?? `supabase functions download ${functionName}` @@ -84,6 +86,7 @@ export const EdgeFunctionBlock = ({ loading={isDeploying} draggable={draggable} onDragStart={onDragStart} + className={className} actions={ hideDeployButton || !onDeploy ? ( (actions ?? null) diff --git a/apps/studio/lib/ai/message-utils.test.ts b/apps/studio/lib/ai/message-utils.test.ts index 0125996ddc7..d5acac0b328 100644 --- a/apps/studio/lib/ai/message-utils.test.ts +++ b/apps/studio/lib/ai/message-utils.test.ts @@ -1,16 +1,21 @@ import type { DynamicToolUIPart, UIMessage } from 'ai' import { describe, expect, it } from 'vitest' -import { getParallelApprovalIdsToReject, prepareMessagesForAPI } from './message-utils' +import { + getParallelApprovalIdsToReject, + isManualApprovalRequested, + prepareMessagesForAPI, +} from './message-utils' -const makeApprovalPart = (id: string): DynamicToolUIPart => ({ - type: 'dynamic-tool', - toolName: 'test_tool', - toolCallId: id, - state: 'approval-requested', - input: {}, - approval: { id }, -}) +const makeApprovalPart = (id: string, isAutomatic = false): DynamicToolUIPart => + ({ + type: 'dynamic-tool', + toolName: 'test_tool', + toolCallId: id, + state: 'approval-requested', + input: {}, + approval: { id, ...(isAutomatic ? { isAutomatic: true } : {}) }, + }) as DynamicToolUIPart const makeResultPart = (id: string): DynamicToolUIPart => ({ type: 'dynamic-tool', @@ -21,6 +26,24 @@ const makeResultPart = (id: string): DynamicToolUIPart => ({ output: {}, }) +describe('isManualApprovalRequested', () => { + it('returns true for a human approval-requested tool part', () => { + expect(isManualApprovalRequested(makeApprovalPart('a1'))).toBe(true) + }) + + it('returns false for an automatic approval', () => { + expect(isManualApprovalRequested(makeApprovalPart('a1', true))).toBe(false) + }) + + it('returns false for a tool result part', () => { + expect(isManualApprovalRequested(makeResultPart('r1'))).toBe(false) + }) + + it('returns false for a content part with no state or approval', () => { + expect(isManualApprovalRequested({ type: 'text', text: 'hello' })).toBe(false) + }) +}) + describe('getParallelApprovalIdsToReject', () => { it('returns [] for empty messages', () => { expect(getParallelApprovalIdsToReject([])).toEqual([]) @@ -75,6 +98,21 @@ describe('getParallelApprovalIdsToReject', () => { ] expect(getParallelApprovalIdsToReject(messages)).toEqual(['a2']) }) + + it('ignores automatic approvals when picking extras to reject', () => { + const messages: UIMessage[] = [ + { + id: '1', + role: 'assistant', + parts: [ + makeApprovalPart('auto', true), + makeApprovalPart('manual-1'), + makeApprovalPart('manual-2'), + ], + }, + ] + expect(getParallelApprovalIdsToReject(messages)).toEqual(['manual-2']) + }) }) describe('prepareMessagesForAPI', () => { diff --git a/apps/studio/lib/ai/message-utils.ts b/apps/studio/lib/ai/message-utils.ts index d04e25b5c1d..bb00d524a2c 100644 --- a/apps/studio/lib/ai/message-utils.ts +++ b/apps/studio/lib/ai/message-utils.ts @@ -1,4 +1,12 @@ -import { isToolUIPart, type UIMessage } from 'ai' +import { + isToolUIPart, + type UIDataTypes, + type UIMessage, + type UIMessagePart, + type UITools, +} from 'ai' + +type UIPart = UIMessagePart /** * Prepares messages for API transmission by cleaning and limiting history @@ -24,6 +32,24 @@ export function prepareMessagesForAPI(messages: UIMessage[]): UIMessage[] { return cleanedMessages } +/** + * Approval id when the part is waiting on a human Approve/Deny. + * Narrows with `isToolUIPart` first, matching the AI SDK `useChat` approval pattern: + * `state === 'approval-requested' && !approval.isAutomatic`. + * + * @see https://ai-sdk.dev/docs/agents/tool-approvals + */ +export function getManualApprovalId(part: UIPart): string | undefined { + if (!isToolUIPart(part) || part.state !== 'approval-requested') return undefined + if ('isAutomatic' in part.approval && part.approval.isAutomatic === true) return undefined + return part.approval.id +} + +/** True when the part is waiting on a human Approve/Deny, not an automatic policy decision. */ +export function isManualApprovalRequested(part: UIPart): boolean { + return getManualApprovalId(part) !== undefined +} + /** * Returns approval IDs to auto-deny when the model issues multiple approval-required * tool calls in the same turn — all but the first, so the model reissues them sequentially. @@ -32,8 +58,10 @@ export function getParallelApprovalIdsToReject(messages: UIMessage[]): string[] const lastMessage = messages.findLast((m) => m.role === 'assistant') if (!lastMessage) return [] - const pendingIds = (lastMessage.parts ?? []).flatMap((part) => - isToolUIPart(part) && part.state === 'approval-requested' ? [part.approval.id] : [] - ) + const pendingIds: string[] = [] + for (const part of lastMessage.parts ?? []) { + const id = getManualApprovalId(part) + if (id) pendingIds.push(id) + } return pendingIds.slice(1) } diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index f1ababc6acb..8629718e8fc 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -731,7 +731,7 @@ export const CHAT_PROMPT = ` - Do not show the SQL query before execution; the client will display it to the user. - Set chartConfig \`view\` to \`chart\` and xAxis/yAxis if the results would be best displayed as a chart e.g. count of items by date - On execution error, explain succinctly and attempt to correct if possible, validating each outcome briefly (1–2 lines) after execution. -- If a user skips execution, acknowledge and suggest alternatives. +- If a user skips execution, acknowledge and suggest alternatives. A skip is a user choice, not a permission or environment error. - Use markdown code blocks (\`\`\`sql\`\`\`) for illustrative SQL only if requested by the user or when providing non-executable examples. - Never call \`execute_sql\` or \`deploy_edge_function\` in parallel within the same step. Each requires user approval, so issue one per step and wait for its result before calling the next. - After execution, summarize outcomes concisely without duplicating results, as the client will present these.