feat(studio): preserve assistant tool previews after completion

This commit is contained in:
Saxon Fletcher authored and Joshen Lim committed 2026-08-21 15:09:10 +08:00
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}