feat(studio): wrap assistant Edge Function approval in a Confirm card (#49168)

<img width="1512" height="862" alt="image"
src="https://github.com/user-attachments/assets/79a6d4dc-dcd2-489f-97d7-3ee7a0196b7d"
/>


## 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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-20 11:35:34 +10:00
1 parent 8a33c094b4
commit bd76d7fc34
17 files changed
+507 -102

No files matched your search

@@ -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<ReportBlockContainerProps>) => {
@@ -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
)}
>
<Tooltip>
<TooltipTrigger asChild>
@@ -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', () => {
@@ -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<UIMessage, 'role' | 'parts
return messages.some((message) => {
if (message.role !== 'assistant') return false
return message.parts?.some((part) => isToolUIPart(part) && part.state === 'approval-requested')
return message.parts?.some((part) => isManualApprovalRequested(part))
})
}
@@ -194,7 +194,7 @@ export const AssistantChat = ({
addToolApprovalResponse,
stop,
regenerate,
} = useChat({
} = useChat<MessageType>({
id: chatId,
...(chatInstance ? { chat: chatInstance } : {}),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
@@ -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<void>
onConfirm?: () => void | Promise<void>
}
/** 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 (
<div
className={cn(
'flex items-center justify-between py-2 pr-2 pl-4 text-xs text-foreground gap-3 shrink-0',
'relative overflow-hidden border-t bg-border shadow-inset',
'bg-linear-to-r from-background-surface-75 to-background-surface-200',
className
)}
>
<div className="flex-1 relative z-10">{message}</div>
<div className="flex items-center gap-2 relative z-10">
<Button size="tiny" variant="outline" onClick={onCancel} disabled={isInactive}>
{cancelLabel}
</Button>
<Button size="tiny" variant="primary" onClick={onConfirm} disabled={isInactive}>
{isLoading ? confirmLabelLoading : confirmLabel}
</Button>
</div>
</div>
)
}
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<void>
onConfirm?: () => void | Promise<void>
}
/**
* 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<ConfirmProps>) => {
const bar = getConfirmFooterBar(state)
const showLoading = bar.isLoading || extraLoading || isLoading
const isApprovalRequested = state === 'approval-requested'
return (
<div
data-slot="assistant-confirm"
className={cn(
'flex flex-col overflow-hidden rounded-md border shadow-xs bg-muted',
fill && 'min-h-64',
className
)}
>
<div className={cn('min-w-0', fill && 'flex min-h-0 flex-1 flex-col overflow-hidden')}>
{children}
</div>
{bar.show && (
<ConfirmFooter
message={message}
cancelLabel={cancelLabel}
confirmLabel={confirmLabel}
confirmLabelLoading={confirmLabelLoading}
isLoading={showLoading}
isDisabled={!isApprovalRequested}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)}
</div>
)
}
@@ -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()
})
})
@@ -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<void>
}): {
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,
}),
}
}
@@ -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<void>
onConfirm?: () => void | Promise<void>
}
export const ConfirmFooter = ({
message,
cancelLabel = 'Cancel',
confirmLabel = 'Confirm',
confirmLabelLoading = 'Working...',
isLoading = false,
onCancel,
onConfirm,
}: PropsWithChildren<ConfirmFooterProps>) => {
return (
<div
className={cn(
'flex items-center justify-between py-2 pr-2 pl-4 text-xs text-foreground',
'relative border border-t-0 overflow-hidden rounded-b-lg bg-border shadow-inset gap-3',
'bg-linear-to-r from-background-surface-75 to-background-surface-200'
)}
>
<div className="flex-1 relative z-10">{message}</div>
<div className="flex items-center gap-2 relative z-10">
<Button size="tiny" variant="outline" onClick={onCancel} disabled={isLoading}>
{cancelLabel}
</Button>
<Button size="tiny" variant="primary" onClick={onConfirm} disabled={isLoading}>
{isLoading ? confirmLabelLoading : confirmLabel}
</Button>
</div>
</div>
)
}
@@ -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'
@@ -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
}) => (
<button tabIndex={0} onClick={onConfirm}>
{confirmLabel ?? 'Confirm'}
</button>
<div>
{children}
<button tabIndex={0} onClick={onConfirm}>
{confirmLabel ?? 'Confirm'}
</button>
</div>
),
}))
@@ -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}
/>
)
@@ -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<EdgeFunctionRendererProps>) => {
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 (
<div className="w-auto overflow-x-hidden my-4">
<Confirm
className="my-4"
state={confirmState}
message="Assistant wants to deploy this Edge Function"
cancelLabel="Skip"
confirmLabel="Deploy"
confirmLabelLoading="Deploying..."
isLoading={isDeploying}
onCancel={onDeny}
onConfirm={handleDeploy}
>
<EdgeFunctionBlock
className="rounded-none border-0 shadow-none"
label={label}
code={code}
functionName={functionName}
disabled={showConfirmFooter}
disabled={isConfirming}
isDeploying={isDeploying}
isDeployed={initialIsDeployed}
functionUrl={functionUrl}
deploymentDetailsUrl={deploymentDetailsUrl}
downloadCommand={downloadCommand}
hideDeployButton={showConfirmFooter || initialIsDeployed}
hideDeployButton={isConfirming || initialIsDeployed}
showReplaceWarning={showReplaceWarning}
onCancelReplace={() => setShowReplaceWarning(false)}
onConfirmReplace={approveDeploy}
/>
{showConfirmFooter && (
<div className="mx-4">
<ConfirmFooter
message="Assistant wants to deploy this Edge Function"
cancelLabel="Skip"
confirmLabel="Deploy"
confirmLabelLoading="Deploying..."
isLoading={isDeploying}
onCancel={() => onDeny?.()}
onConfirm={handleDeploy}
/>
</div>
)}
</div>
</Confirm>
)
}
@@ -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 (
<EdgeFunctionRenderer
label={parsedInput.data.label}
code={parsedInput.data.code}
functionName={parsedInput.data.functionName}
showConfirmFooter={state === 'approval-requested'}
isDeploying={state === 'approval-responded' && toolPart.approval?.approved !== false}
confirmState={confirmState}
isDeploying={confirmState === 'approval-responded'}
initialIsDeployed={isInitiallyDeployed}
onApprove={
approvalId ? () => addToolApprovalResponse?.({ id: approvalId, approved: true }) : undefined
}
onDeny={
approvalId
? () => addToolApprovalResponse?.({ id: approvalId, approved: false })
: undefined
}
onApprove={onApprove}
onDeny={onDeny}
/>
)
}
@@ -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 (
<ConfirmFooter
className={FLUSH_UNDER_BLOCK}
message={message ?? copy.confirmMessage}
cancelLabel="Skip"
confirmLabel={confirmLabel ?? copy.confirmLabel}
@@ -48,6 +48,7 @@ interface EdgeFunctionBlockProps {
onConfirmReplace?: () => 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)
+47 -9
View File
@@ -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', () => {
+32 -4
View File
@@ -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<UIDataTypes, UITools>
/**
* 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)
}
+1 -1
View File
@@ -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.