mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user