mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
feat(studio): preserve assistant tool previews after completion
This commit is contained in:
1 parent
86105ca5ec
commit
e709eee7a7
10 files changed
+247
-99
No files matched your search
@@ -133,6 +133,9 @@ export const AssistantQueryCell = ({
|
||||
cancelLabel="Skip"
|
||||
confirmLabel="Run query"
|
||||
confirmLabelLoading="Running..."
|
||||
successMessage="Query executed"
|
||||
errorMessage="Failed to execute SQL"
|
||||
deniedMessage="Skipped query"
|
||||
onCancel={onDeny}
|
||||
onConfirm={onApprove}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Confirm } from './Confirm'
|
||||
|
||||
describe('Confirm', () => {
|
||||
it('keeps its content visible and replaces actions with a success status', () => {
|
||||
render(
|
||||
<Confirm state="success" message="Run query" successMessage="Query executed">
|
||||
<div>Query preview</div>
|
||||
</Confirm>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Query preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('Query executed')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps its content visible and replaces actions with an error status', () => {
|
||||
render(
|
||||
<Confirm state="error" message="Run query" errorMessage="Failed to execute SQL">
|
||||
<div>Query preview</div>
|
||||
</Confirm>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Query preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('Failed to execute SQL')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type PropsWithChildren } from 'react'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { type PropsWithChildren, type ReactNode } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
import { getConfirmFooterBar } from './Confirm.utils'
|
||||
import { getConfirmFooterBar, type ConfirmFooterApprovalState } from './Confirm.utils'
|
||||
|
||||
interface ConfirmFooterProps {
|
||||
message: string
|
||||
@@ -10,6 +11,9 @@ interface ConfirmFooterProps {
|
||||
confirmLabelLoading?: string
|
||||
isLoading?: boolean
|
||||
isDisabled?: boolean
|
||||
outcome?: 'success' | 'error' | 'denied'
|
||||
showActions?: boolean
|
||||
action?: ReactNode
|
||||
/** Omit the confirm button so only Skip remains (unparseable / unapplyable previews). */
|
||||
denyOnly?: boolean
|
||||
/** Escape hatch for consumers that attach the bar directly under their own frame. */
|
||||
@@ -26,6 +30,9 @@ export const ConfirmFooter = ({
|
||||
confirmLabelLoading = 'Working...',
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
outcome,
|
||||
showActions = true,
|
||||
action,
|
||||
denyOnly = false,
|
||||
className,
|
||||
onCancel,
|
||||
@@ -41,17 +48,28 @@ export const ConfirmFooter = ({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">{message}</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="tiny" variant="outline" onClick={onCancel} disabled={isInactive}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
{!denyOnly && (
|
||||
<Button size="tiny" variant="primary" onClick={onConfirm} disabled={isInactive}>
|
||||
{isLoading ? confirmLabelLoading : confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
{outcome === 'success' && <Check className="size-3.5 shrink-0 text-brand" />}
|
||||
{outcome === 'error' && <X className="size-3.5 shrink-0 text-danger" />}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{(showActions || action) && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showActions && (
|
||||
<>
|
||||
<Button size="tiny" variant="outline" onClick={onCancel} disabled={isInactive}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
{!denyOnly && (
|
||||
<Button size="tiny" variant="primary" onClick={onConfirm} disabled={isInactive}>
|
||||
{isLoading ? confirmLabelLoading : confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -61,11 +79,15 @@ interface ConfirmProps {
|
||||
* Result of `getManualToolApprovalConfirmState`. Interactive buttons only for
|
||||
* `approval-requested`; `approval-responded` is the post-approve loading morph.
|
||||
*/
|
||||
state?: string
|
||||
state?: ConfirmFooterApprovalState
|
||||
message: string
|
||||
cancelLabel?: string
|
||||
confirmLabel?: string
|
||||
confirmLabelLoading?: string
|
||||
successMessage?: string
|
||||
errorMessage?: string
|
||||
deniedMessage?: string
|
||||
footerAction?: ReactNode
|
||||
extraLoading?: boolean
|
||||
isLoading?: boolean
|
||||
/**
|
||||
@@ -93,6 +115,10 @@ export const Confirm = ({
|
||||
cancelLabel = 'Skip',
|
||||
confirmLabel = 'Confirm',
|
||||
confirmLabelLoading = 'Working...',
|
||||
successMessage,
|
||||
errorMessage,
|
||||
deniedMessage,
|
||||
footerAction,
|
||||
extraLoading = false,
|
||||
isLoading = false,
|
||||
fill = false,
|
||||
@@ -104,6 +130,14 @@ export const Confirm = ({
|
||||
const bar = getConfirmFooterBar(state)
|
||||
const showLoading = bar.isLoading || extraLoading || isLoading
|
||||
const isApprovalRequested = state === 'approval-requested'
|
||||
const footerMessage =
|
||||
bar.outcome === 'success'
|
||||
? (successMessage ?? message)
|
||||
: bar.outcome === 'error'
|
||||
? (errorMessage ?? message)
|
||||
: bar.outcome === 'denied'
|
||||
? (deniedMessage ?? message)
|
||||
: message
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -120,12 +154,15 @@ export const Confirm = ({
|
||||
</div>
|
||||
{bar.show && (
|
||||
<ConfirmFooter
|
||||
message={message}
|
||||
message={footerMessage}
|
||||
cancelLabel={cancelLabel}
|
||||
confirmLabel={confirmLabel}
|
||||
confirmLabelLoading={confirmLabelLoading}
|
||||
isLoading={showLoading}
|
||||
isDisabled={!isApprovalRequested}
|
||||
outcome={bar.outcome}
|
||||
showActions={isApprovalRequested}
|
||||
action={footerAction}
|
||||
denyOnly={denyOnly}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
|
||||
@@ -21,10 +21,22 @@ describe('getConfirmFooterBar', () => {
|
||||
expect(getConfirmFooterBar('approval-responded')).toEqual({ show: true, isLoading: true })
|
||||
})
|
||||
|
||||
it('hides the bar for every other tool state', () => {
|
||||
expect(getConfirmFooterBar('input-available')).toEqual({ show: false, isLoading: false })
|
||||
expect(getConfirmFooterBar('output-available')).toEqual({ show: false, isLoading: false })
|
||||
expect(getConfirmFooterBar('output-denied')).toEqual({ show: false, isLoading: false })
|
||||
it('shows a terminal outcome after a completed approval', () => {
|
||||
expect(getConfirmFooterBar('success')).toEqual({
|
||||
show: true,
|
||||
isLoading: false,
|
||||
outcome: 'success',
|
||||
})
|
||||
expect(getConfirmFooterBar('error')).toEqual({
|
||||
show: true,
|
||||
isLoading: false,
|
||||
outcome: 'error',
|
||||
})
|
||||
expect(getConfirmFooterBar('denied')).toEqual({
|
||||
show: true,
|
||||
isLoading: false,
|
||||
outcome: 'denied',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +59,27 @@ describe('getManualToolApprovalConfirmState', () => {
|
||||
).toBe('approval-responded')
|
||||
})
|
||||
|
||||
it('keeps a terminal footer after a manual tool completes', () => {
|
||||
expect(
|
||||
getManualToolApprovalConfirmState({
|
||||
state: 'output-available',
|
||||
approval: { id: 'approval-1', approved: true },
|
||||
})
|
||||
).toBe('success')
|
||||
expect(
|
||||
getManualToolApprovalConfirmState({
|
||||
state: 'output-error',
|
||||
approval: { id: 'approval-1', approved: true },
|
||||
})
|
||||
).toBe('error')
|
||||
expect(
|
||||
getManualToolApprovalConfirmState({
|
||||
state: 'output-denied',
|
||||
approval: { id: 'approval-1', approved: false },
|
||||
})
|
||||
).toBe('denied')
|
||||
})
|
||||
|
||||
it('hides the footer for automatic approvals', () => {
|
||||
expect(
|
||||
getManualToolApprovalConfirmState({
|
||||
@@ -71,9 +104,10 @@ describe('getManualToolApprovalConfirmState', () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores non-approval tool states', () => {
|
||||
it('ignores terminal states that were not manually approved', () => {
|
||||
expect(getManualToolApprovalConfirmState({ state: 'input-available' })).toBeUndefined()
|
||||
expect(getManualToolApprovalConfirmState({ state: 'output-available' })).toBeUndefined()
|
||||
expect(getManualToolApprovalConfirmState({ state: 'output-error' })).toBeUndefined()
|
||||
expect(getManualToolApprovalConfirmState({ state: 'output-denied' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export type ConfirmFooterApprovalState = 'approval-requested' | 'approval-responded'
|
||||
export type ConfirmFooterApprovalState =
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'denied'
|
||||
|
||||
/** Sent with Skip so the model sees a user choice, not the SDK default "Tool execution denied." */
|
||||
export const USER_SKIPPED_TOOL_REASON = 'The user skipped this action.'
|
||||
@@ -11,20 +16,27 @@ export type ToolApprovalFields = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the confirm bar should render, and whether it is in the post-approve loading
|
||||
* morph. Driven by the AI SDK tool approval state; any other state hides the bar.
|
||||
* Whether the confirm bar should render, and whether it is loading or in a terminal state.
|
||||
* Driven by the AI SDK tool approval state; any other state hides the bar.
|
||||
*/
|
||||
export function getConfirmFooterBar(state?: string): { show: boolean; isLoading: boolean } {
|
||||
export function getConfirmFooterBar(state?: ConfirmFooterApprovalState): {
|
||||
show: boolean
|
||||
isLoading: boolean
|
||||
outcome?: 'success' | 'error' | 'denied'
|
||||
} {
|
||||
if (state === 'approval-requested') return { show: true, isLoading: false }
|
||||
if (state === 'approval-responded') return { show: true, isLoading: true }
|
||||
if (state === 'success' || state === 'error' || state === 'denied') {
|
||||
return { show: true, isLoading: false, outcome: state }
|
||||
}
|
||||
return { show: false, isLoading: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a tool part onto the confirm footer. Follows the AI SDK `useChat` rule:
|
||||
* interactive Approve/Deny only for `approval-requested` when `!approval.isAutomatic`.
|
||||
* `approval-responded` keeps a loading morph after a manual approve; denials and
|
||||
* automatic decisions hide the bar.
|
||||
* `approval-responded` keeps a loading morph after a manual approve. Completed manual
|
||||
* approvals stay visible as a terminal outcome; automatic decisions never get a footer.
|
||||
*
|
||||
* @see https://ai-sdk.dev/docs/agents/tool-approvals
|
||||
*/
|
||||
@@ -38,6 +50,9 @@ export function getManualToolApprovalConfirmState({
|
||||
if (approval?.isAutomatic) return undefined
|
||||
if (state === 'approval-requested') return 'approval-requested'
|
||||
if (state === 'approval-responded' && approval?.approved !== false) return 'approval-responded'
|
||||
if (state === 'output-available' && approval?.approved === true) return 'success'
|
||||
if (state === 'output-error' && approval?.approved === true) return 'error'
|
||||
if (state === 'output-denied' && approval?.approved === false) return 'denied'
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ interface EdgeFunctionRendererProps {
|
||||
onDeny?: () => void
|
||||
isDeploying?: boolean
|
||||
initialIsDeployed?: boolean
|
||||
errorText?: string
|
||||
confirmState?: ConfirmFooterApprovalState
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export const EdgeFunctionRenderer = ({
|
||||
onDeny,
|
||||
isDeploying = false,
|
||||
initialIsDeployed,
|
||||
errorText,
|
||||
confirmState,
|
||||
}: EdgeFunctionRendererProps) => {
|
||||
const { ref } = useParams()
|
||||
@@ -85,6 +87,9 @@ export const EdgeFunctionRenderer = ({
|
||||
cancelLabel="Skip"
|
||||
confirmLabel="Deploy"
|
||||
confirmLabelLoading="Deploying..."
|
||||
successMessage="Edge Function deployed"
|
||||
errorMessage="Failed to deploy Edge Function"
|
||||
deniedMessage="Skipped Edge Function deployment"
|
||||
isLoading={isDeploying}
|
||||
onCancel={onDeny}
|
||||
onConfirm={handleDeploy}
|
||||
@@ -97,6 +102,7 @@ export const EdgeFunctionRenderer = ({
|
||||
disabled={isConfirming}
|
||||
isDeploying={isDeploying}
|
||||
isDeployed={initialIsDeployed}
|
||||
errorText={errorText}
|
||||
functionUrl={functionUrl}
|
||||
deploymentDetailsUrl={deploymentDetailsUrl}
|
||||
downloadCommand={downloadCommand}
|
||||
|
||||
@@ -110,24 +110,17 @@ function ToolDisplayExecuteSqlLoading({ label = 'Writing SQL...' }: { label?: st
|
||||
)
|
||||
}
|
||||
|
||||
function ToolDisplayExecuteSqlFailure() {
|
||||
return <div className="text-xs text-danger">Failed to execute SQL.</div>
|
||||
}
|
||||
|
||||
function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) {
|
||||
const { id } = useMessageInfoContext()
|
||||
const { addToolApprovalResponse } = useMessageActionsContext()
|
||||
|
||||
const { toolCallId, state, input, output } = toolPart
|
||||
const { toolCallId, state, input: submittedInput, output } = toolPart
|
||||
const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
|
||||
|
||||
if (state === 'input-streaming') {
|
||||
return <ToolDisplayExecuteSqlLoading />
|
||||
}
|
||||
|
||||
if (state === 'output-error') {
|
||||
return <ToolDisplayExecuteSqlFailure />
|
||||
}
|
||||
|
||||
const { data: chart, success } = parseExecuteSqlChartResult(input)
|
||||
if (!success) return null
|
||||
|
||||
@@ -136,7 +129,8 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) {
|
||||
state === 'approval-requested' ||
|
||||
state === 'approval-responded' ||
|
||||
state === 'output-denied' ||
|
||||
state === 'output-available'
|
||||
state === 'output-available' ||
|
||||
state === 'output-error'
|
||||
) {
|
||||
const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({
|
||||
state,
|
||||
@@ -150,7 +144,11 @@ function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) {
|
||||
id={`${id}-${toolCallId}`}
|
||||
sql={chart.sql}
|
||||
title={chart.label}
|
||||
initialResult={toAssistantQueryResult(output)}
|
||||
initialResult={
|
||||
state === 'output-error'
|
||||
? { rows: [], error: { message: toolPart.errorText ?? 'Failed to execute SQL' } }
|
||||
: toAssistantQueryResult(output)
|
||||
}
|
||||
view={chart.view}
|
||||
xAxis={chart.xAxis}
|
||||
yAxis={chart.yAxis}
|
||||
@@ -171,10 +169,12 @@ const TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT = new Set([
|
||||
'approval-responded',
|
||||
'output-denied',
|
||||
'output-available',
|
||||
'output-error',
|
||||
])
|
||||
|
||||
function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) {
|
||||
const { state, input, output } = toolPart
|
||||
const { state, input: submittedInput, output } = toolPart
|
||||
const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
|
||||
const { addToolApprovalResponse } = useMessageActionsContext()
|
||||
|
||||
if (state === 'input-streaming') {
|
||||
@@ -186,10 +186,6 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'output-error') {
|
||||
return <p className="text-xs text-danger">Failed to deploy Edge Function.</p>
|
||||
}
|
||||
|
||||
if (!TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT.has(state)) return null
|
||||
|
||||
const parsedInput = deployEdgeFunctionInputSchema.safeParse(input)
|
||||
@@ -213,6 +209,7 @@ function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) {
|
||||
confirmState={confirmState}
|
||||
isDeploying={confirmState === 'approval-responded'}
|
||||
initialIsDeployed={isInitiallyDeployed}
|
||||
errorText={state === 'output-error' ? toolPart.errorText : undefined}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
@@ -224,11 +221,6 @@ const NOTEBOOK_DRAFTING_LABEL: Record<NotebookProposalMode, string> = {
|
||||
update: 'Drafting notebook update...',
|
||||
}
|
||||
|
||||
const NOTEBOOK_FAILED_LABEL: Record<NotebookProposalMode, string> = {
|
||||
create: 'Failed to create notebook.',
|
||||
update: 'Failed to update notebook.',
|
||||
}
|
||||
|
||||
function MessagePartNotebookProposal({
|
||||
toolPart,
|
||||
mode,
|
||||
@@ -236,7 +228,8 @@ function MessagePartNotebookProposal({
|
||||
toolPart: ToolUIPart
|
||||
mode: NotebookProposalMode
|
||||
}) {
|
||||
const { state, input, output } = toolPart
|
||||
const { state, input: submittedInput, output } = toolPart
|
||||
const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
|
||||
const { addToolApprovalResponse } = useMessageActionsContext()
|
||||
|
||||
if (state === 'input-streaming') {
|
||||
@@ -248,10 +241,6 @@ function MessagePartNotebookProposal({
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'output-error') {
|
||||
return <p className="text-xs text-danger px-4">{NOTEBOOK_FAILED_LABEL[mode]}</p>
|
||||
}
|
||||
|
||||
const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({
|
||||
state,
|
||||
approval: toolPart.approval,
|
||||
|
||||
@@ -10,15 +10,11 @@ import {
|
||||
toQueryLogsResult,
|
||||
} from './MessagePartQueryLogs.utils'
|
||||
|
||||
type QueryLogsToolPart = Pick<ToolUIPart, 'toolCallId' | 'state' | 'input' | 'output'>
|
||||
|
||||
function QueryLogsFailure() {
|
||||
return <div className="text-xs text-danger">Failed to query logs.</div>
|
||||
}
|
||||
type QueryLogsToolPart = Pick<ToolUIPart, 'toolCallId' | 'state' | 'input' | 'output' | 'errorText'>
|
||||
|
||||
export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart }) {
|
||||
const { id } = useMessageInfoContext()
|
||||
const { toolCallId, state, input, output } = toolPart
|
||||
const { toolCallId, state, input: submittedInput, output } = toolPart
|
||||
|
||||
if (state === 'input-streaming' || state === 'input-available') {
|
||||
return (
|
||||
@@ -29,14 +25,16 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'output-error') return <QueryLogsFailure />
|
||||
if (state !== 'output-available') return null
|
||||
if (state !== 'output-available' && state !== 'output-error') return null
|
||||
|
||||
const parsedInput = parseQueryLogsInput(input)
|
||||
const result = toQueryLogsResult(output)
|
||||
if (!parsedInput.success || !result) {
|
||||
return <QueryLogsFailure />
|
||||
}
|
||||
const parsedInput = parseQueryLogsInput(submittedInput)
|
||||
if (!parsedInput.success) return null
|
||||
|
||||
const result =
|
||||
state === 'output-error'
|
||||
? { rows: [], error: { message: toolPart.errorText ?? 'Failed to query logs' } }
|
||||
: toQueryLogsResult(output)
|
||||
if (!result) return null
|
||||
|
||||
return (
|
||||
<div className="w-auto overflow-x-hidden my-4 space-y-2">
|
||||
@@ -52,6 +50,7 @@ export function MessagePartQueryLogs({ toolPart }: { toolPart: QueryLogsToolPart
|
||||
),
|
||||
}}
|
||||
initialResult={result}
|
||||
confirmState={state === 'output-error' ? 'error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -151,18 +151,53 @@ describe('NotebookProposalRenderer', () => {
|
||||
expect(onApprove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders an Open notebook link once output is available', () => {
|
||||
it('keeps the create preview and marks it successful once output is available', () => {
|
||||
render(
|
||||
<NotebookProposalRenderer
|
||||
mode="create"
|
||||
state="output-available"
|
||||
input={{}}
|
||||
confirmState="success"
|
||||
input={{
|
||||
name: 'Signup funnel',
|
||||
content: {
|
||||
schema_version: 1,
|
||||
cells: [{ _tag: 'markdown_cell', text: 'hello' }],
|
||||
},
|
||||
}}
|
||||
output={{ id: NOTEBOOK_ID, name: 'Signup funnel' }}
|
||||
/>
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Open notebook' })
|
||||
expect(link).toHaveAttribute('href', `/project/default/explorer/notebook/${NOTEBOOK_ID}`)
|
||||
expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Signup funnel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Notebook created')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Open notebook' })).toHaveAttribute(
|
||||
'href',
|
||||
`/project/default/explorer/notebook/${NOTEBOOK_ID}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the create preview and marks it failed when the tool errors', () => {
|
||||
render(
|
||||
<NotebookProposalRenderer
|
||||
mode="create"
|
||||
state="output-error"
|
||||
confirmState="error"
|
||||
input={{
|
||||
name: 'New notebook',
|
||||
content: {
|
||||
schema_version: 1,
|
||||
cells: [{ _tag: 'markdown_cell', text: 'hello' }],
|
||||
},
|
||||
}}
|
||||
output={undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument()
|
||||
expect(screen.getByText('New notebook')).toBeInTheDocument()
|
||||
expect(screen.getByText('Failed to create notebook')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the preview in the message after skip', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { type PropsWithChildren } from 'react'
|
||||
import { type PropsWithChildren, type ReactNode } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
@@ -25,14 +25,13 @@ import { toWireNotebook } from '@/data/content/notebooks/notebook-schema'
|
||||
|
||||
export type NotebookProposalMode = 'create' | 'update'
|
||||
|
||||
// input-streaming and output-error are handled by the caller before this component is
|
||||
// rendered — see MessagePartNotebookProposal in Message.Parts.tsx.
|
||||
export type NotebookProposalState =
|
||||
| 'input-available'
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'output-denied'
|
||||
| 'output-available'
|
||||
| 'output-error'
|
||||
|
||||
export interface NotebookProposalRendererProps {
|
||||
mode: NotebookProposalMode
|
||||
@@ -45,7 +44,12 @@ export interface NotebookProposalRendererProps {
|
||||
onDeny?: () => void
|
||||
}
|
||||
|
||||
type NotebookProposalStepProps = Omit<NotebookProposalRendererProps, 'mode' | 'output' | 'state'>
|
||||
type NotebookProposalStepProps = Omit<
|
||||
NotebookProposalRendererProps,
|
||||
'mode' | 'output' | 'state'
|
||||
> & {
|
||||
footerAction?: ReactNode
|
||||
}
|
||||
|
||||
const MODE_COPY = {
|
||||
create: {
|
||||
@@ -69,17 +73,23 @@ const MODE_COPY = {
|
||||
* same way `AssistantQueryCell` wraps `QueryEditor`.
|
||||
*/
|
||||
export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) => {
|
||||
const { mode, state, output } = props
|
||||
const { ref } = useParams()
|
||||
const { mode, state, input, output, confirmState, onApprove, onDeny } = props
|
||||
const parsedOutput = notebookToolOutputSchema.safeParse(output)
|
||||
const footerAction =
|
||||
state === 'output-available' && parsedOutput.success && ref ? (
|
||||
<Button asChild variant="default" size="tiny">
|
||||
<Link href={`/project/${ref}/explorer/notebook/${parsedOutput.data.id}`}>
|
||||
Open notebook
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
|
||||
if (state === 'output-available') {
|
||||
return <NotebookOutputSummary mode={mode} output={output} />
|
||||
}
|
||||
|
||||
const { input, confirmState, onApprove, onDeny } = props
|
||||
return mode === 'create' ? (
|
||||
<CreateNotebookProposal
|
||||
input={input}
|
||||
confirmState={confirmState}
|
||||
footerAction={footerAction}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
@@ -87,33 +97,13 @@ export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) =
|
||||
<UpdateNotebookProposal
|
||||
input={input}
|
||||
confirmState={confirmState}
|
||||
footerAction={footerAction}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NotebookOutputSummary({ mode, output }: { mode: NotebookProposalMode; output: unknown }) {
|
||||
const { ref } = useParams()
|
||||
const parsedOutput = notebookToolOutputSchema.safeParse(output)
|
||||
const label = MODE_COPY[mode].outputLabel
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 my-2 mx-4 px-3 py-1.5 text-sm border rounded-md bg-surface-75">
|
||||
<span className="text-foreground-light truncate">
|
||||
{parsedOutput.success ? `${label}: ${parsedOutput.data.name}` : label}
|
||||
</span>
|
||||
{parsedOutput.success && ref && (
|
||||
<Button asChild variant="default" size="tiny">
|
||||
<Link href={`/project/${ref}/explorer/notebook/${parsedOutput.data.id}`}>
|
||||
Open notebook
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NotebookConfirmProps {
|
||||
mode: NotebookProposalMode
|
||||
confirmState?: ConfirmFooterApprovalState
|
||||
@@ -122,6 +112,7 @@ interface NotebookConfirmProps {
|
||||
confirmLabelLoading?: string
|
||||
extraLoading?: boolean
|
||||
denyOnly?: boolean
|
||||
footerAction?: ReactNode
|
||||
onApprove?: () => void
|
||||
onDeny?: () => void
|
||||
}
|
||||
@@ -135,6 +126,7 @@ function NotebookConfirm({
|
||||
confirmLabelLoading,
|
||||
extraLoading,
|
||||
denyOnly,
|
||||
footerAction,
|
||||
onApprove,
|
||||
onDeny,
|
||||
children,
|
||||
@@ -149,6 +141,10 @@ function NotebookConfirm({
|
||||
cancelLabel="Skip"
|
||||
confirmLabel={confirmLabel ?? copy.confirmLabel}
|
||||
confirmLabelLoading={confirmLabelLoading ?? copy.confirmLabelLoading}
|
||||
successMessage={copy.outputLabel}
|
||||
errorMessage={`Failed to ${mode} notebook`}
|
||||
deniedMessage={`Skipped notebook ${mode === 'create' ? 'creation' : 'update'}`}
|
||||
footerAction={footerAction}
|
||||
extraLoading={extraLoading}
|
||||
denyOnly={denyOnly}
|
||||
onCancel={onDeny}
|
||||
@@ -188,6 +184,7 @@ function NotebookParseFailure({
|
||||
function CreateNotebookProposal({
|
||||
input,
|
||||
confirmState,
|
||||
footerAction,
|
||||
onApprove,
|
||||
onDeny,
|
||||
}: NotebookProposalStepProps) {
|
||||
@@ -216,6 +213,7 @@ function CreateNotebookProposal({
|
||||
<NotebookConfirm
|
||||
mode="create"
|
||||
confirmState={confirmState}
|
||||
footerAction={footerAction}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
>
|
||||
@@ -227,6 +225,7 @@ function CreateNotebookProposal({
|
||||
function UpdateNotebookProposal({
|
||||
input,
|
||||
confirmState,
|
||||
footerAction,
|
||||
onApprove,
|
||||
onDeny,
|
||||
}: NotebookProposalStepProps) {
|
||||
@@ -308,6 +307,7 @@ function UpdateNotebookProposal({
|
||||
<NotebookConfirm
|
||||
mode="update"
|
||||
confirmState={confirmState}
|
||||
footerAction={footerAction}
|
||||
message={`Assistant wants to update "${notebook.name}"`}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
|
||||
Reference in new issue
Block a user