import { useParams } from 'common' import { Loader2 } from 'lucide-react' import Link from 'next/link' import { useEffect, useEffectEvent, type PropsWithChildren, type ReactNode } from 'react' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { AssistantNotebookPreview } from './AssistantNotebookPreview' import { Confirm } from './Confirm' import { type ConfirmFooterApprovalState } from './Confirm.utils' import { createNotebookInputSchema, deleteNotebookInputSchema, notebookToolOutputSchema, updateNotebookInputSchema, updateNotebookToolOutputSchema, } from './Message.utils' import { AlertError } from '@/components/ui/AlertError' import { hasDiscardableChanges } from '@/data/content/notebooks/notebook-cache' import { deriveNotebookDiff, describeNotebookOperationError, type NotebookCellDiffEntry, } from '@/data/content/notebooks/notebook-operations' import { useNotebookQuery } from '@/data/content/notebooks/notebook-query' import { toWireNotebook } from '@/data/content/notebooks/notebook-schema' import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' export type NotebookProposalMode = 'create' | 'update' | 'delete' export type NotebookProposalState = | 'input-available' | 'approval-requested' | 'approval-responded' | 'output-denied' | 'output-available' | 'output-error' export interface NotebookProposalRendererProps { mode: NotebookProposalMode state: NotebookProposalState input: unknown output: unknown /** Result of `getManualToolApprovalConfirmState`. Drives the Confirm footer. */ confirmState?: ConfirmFooterApprovalState onApprove?: () => void onDeny?: () => void /** Denies with a specific reason instead of a generic "user skipped" — used to auto-deny * an update that can't be applied as written so the model sees why and can retry. */ denyWithReason?: (reason: string) => void } type NotebookProposalStepProps = Omit< NotebookProposalRendererProps, 'mode' | 'output' | 'state' > & { footerAction?: ReactNode } /** For steps (update, delete) that branch on the tool's live state/output, unlike create. */ type NotebookProposalStepPropsWithOutput = NotebookProposalStepProps & { state: NotebookProposalState output: unknown } const MODE_COPY = { create: { confirmMessage: 'Assistant wants to create this notebook', confirmLabel: 'Create', confirmLabelLoading: 'Creating...', outputLabel: 'Notebook created', }, update: { confirmMessage: 'Assistant wants to update this notebook', confirmLabel: 'Apply changes', confirmLabelLoading: 'Applying changes...', outputLabel: 'Notebook updated', }, delete: { confirmMessage: 'Assistant wants to delete this notebook', confirmLabel: 'Delete', confirmLabelLoading: 'Deleting...', outputLabel: 'Notebook deleted', }, } as const const NOTEBOOK_ACTION_NOUN: Record = { create: 'creation', update: 'update', delete: 'deletion', } /** * Renders the create/update notebook tool across all approval states. Owns input parsing * (via the shared `agentNotebookSchema` / `notebookOperationsSchema`), the update-mode diff * fetch, and the `expected_updated_at` version check. Wraps the preview in `Confirm` the * same way `AssistantQueryCell` wraps `QueryEditor`. */ export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) => { const { ref } = useParams() const { mode, state, input, output, confirmState, onApprove, onDeny, denyWithReason } = props if ( mode === 'update' && (state === 'output-available' || state === 'output-error' || state === 'output-denied') ) { return } const parsedOutput = notebookToolOutputSchema.safeParse(output) // A deleted notebook no longer exists to open, so this action only applies to create/update. const canOpenNotebook = mode !== 'delete' && state === 'output-available' const footerAction = canOpenNotebook && parsedOutput.success && ref ? ( ) : undefined const proposal = (mode === 'create' && ( )) || (mode === 'update' && ( )) || ( ) return ( <> {proposal} {confirmState === undefined && footerAction} ) } function UpdateNotebookTerminalSummary({ state, output, }: Pick) { const { ref } = useParams() const parsedOutput = notebookToolOutputSchema.safeParse(output) const label = state === 'output-available' ? parsedOutput.success ? `Notebook updated: ${parsedOutput.data.name}` : 'Notebook updated' : state === 'output-error' ? 'Failed to update notebook' : 'Skipped notebook update' return (
{label} {state === 'output-available' && parsedOutput.success && ref && ( )}
) } interface NotebookConfirmProps { mode: NotebookProposalMode confirmState?: ConfirmFooterApprovalState message?: string confirmLabel?: string confirmLabelLoading?: string extraLoading?: boolean denyOnly?: boolean footerAction?: ReactNode onApprove?: () => void onDeny?: () => void } /** Confirm card around a notebook preview, matching AssistantQueryCell / EdgeFunctionRenderer. */ function NotebookConfirm({ mode, confirmState, message, confirmLabel, confirmLabelLoading, extraLoading, denyOnly, footerAction, onApprove, onDeny, children, }: PropsWithChildren) { const copy = MODE_COPY[mode] return ( {children} ) } function NotebookParseFailure({ mode, confirmState, input, onDeny, }: Pick) { return (
) } function CreateNotebookProposal({ input, confirmState, footerAction, onApprove, onDeny, }: NotebookProposalStepProps) { const parsedInput = createNotebookInputSchema.safeParse(input) if (!parsedInput.success) { return ( ) } const entries: NotebookCellDiffEntry[] = parsedInput.data.content.cells.map( (cell, operationIndex) => ({ _tag: 'added', cell, operationIndex, }) ) return ( ) } const TERMINAL_CONFIRM_STATES: ConfirmFooterApprovalState[] = ['success', 'error', 'denied'] /** * An update whose operations don't apply to the notebook as currently loaded * (e.g. an operation targets a cell id that no longer exists). There's nothing * for the user to decide here, so instead of asking them to Skip, deny * automatically with the specific reason — same text `update_notebook`'s * server-side execute() would throw for the same failure — so the model sees * why and can retry (e.g. re-fetch and reissue) without the user's involvement. * * For a terminal `confirmState` (the decision already happened), re-deriving * against live content is just for display, and "can't be applied as written" * is inaccurate — nothing is being applied anymore. Instead, state that the * notebook has changed since, so the preview can't be reconstructed. */ function UnapplyableNotebookUpdateNotice({ notebookName, reason, confirmState, footerAction, onDeny, denyWithReason, }: { notebookName: string reason: string confirmState?: ConfirmFooterApprovalState footerAction?: ReactNode onDeny?: () => void denyWithReason?: (reason: string) => void }) { const onUnapplyable = useEffectEvent(() => { denyWithReason?.(reason) }) useEffect(() => { if (confirmState === 'approval-requested') onUnapplyable() }, [confirmState]) const isTerminal = confirmState !== undefined && TERMINAL_CONFIRM_STATES.includes(confirmState) return ( (denyWithReason ? denyWithReason(reason) : onDeny?.())} >
{isTerminal ? ( ) : ( )}
) } function UpdateNotebookProposal({ input, state, output, confirmState, footerAction, onApprove, onDeny, denyWithReason, }: NotebookProposalStepPropsWithOutput) { const { ref } = useParams() const notebooksSnap = useNotebooksStateSnapshot() const parsedInput = updateNotebookInputSchema.safeParse(input) const isCompleted = state === 'output-available' const hasUnsavedLocalChanges = confirmState === 'approval-requested' && parsedInput.success && hasDiscardableChanges(notebooksSnap.notebooks[parsedInput.data.id]) const { data: notebook, isLoading, isError, error, } = useNotebookQuery( { projectRef: ref, id: parsedInput.success ? parsedInput.data.id : undefined }, { enabled: parsedInput.success && !isCompleted } ) if (!parsedInput.success) { return ( ) } if (isCompleted) { const parsedOutput = updateNotebookToolOutputSchema.safeParse(output) const notebookName = parsedOutput.success ? parsedOutput.data.name : undefined const isOutputForRequestedNotebook = parsedOutput.success && parsedOutput.data.id === parsedInput.data.id const previousContent = isOutputForRequestedNotebook ? parsedOutput.data.previous_content : undefined const diff = previousContent ? deriveNotebookDiff(previousContent, parsedInput.data.operations) : undefined if (diff?.success) { return ( ) } return (
{notebookName ? `Notebook updated: ${notebookName}` : MODE_COPY.update.outputLabel}
) } if (isLoading) { return (
Loading notebook...
) } if (isError || !notebook) { return (
{confirmState !== undefined && ( )}
) } const diff = deriveNotebookDiff(toWireNotebook(notebook.content), parsedInput.data.operations) if (!diff.success) { return ( ) } return ( {hasUnsavedLocalChanges && } ) } function DeleteNotebookProposal({ input, state, output, confirmState, onApprove, onDeny, }: NotebookProposalStepPropsWithOutput) { const { ref } = useParams() const notebooksSnap = useNotebooksStateSnapshot() const parsedInput = deleteNotebookInputSchema.safeParse(input) const isCompleted = state === 'output-available' const hasUnsavedLocalChanges = confirmState === 'approval-requested' && parsedInput.success && hasDiscardableChanges(notebooksSnap.notebooks[parsedInput.data.id]) const { data: notebook, isLoading, isError, error, } = useNotebookQuery( { projectRef: ref, id: parsedInput.success ? parsedInput.data.id : undefined }, { enabled: parsedInput.success && !isCompleted } ) if (!parsedInput.success) { return ( ) } if (isCompleted) { const parsedOutput = notebookToolOutputSchema.safeParse(output) const notebookName = parsedOutput.success ? parsedOutput.data.name : undefined return (
{notebookName ? `Notebook deleted: ${notebookName}` : MODE_COPY.delete.outputLabel}
) } if (isLoading) { return (
Loading notebook...
) } if (isError || !notebook) { return (
{confirmState !== undefined && ( )}
) } return ( {hasUnsavedLocalChanges && }
) } function UnsavedLocalChangesWarning({ mode }: { mode: 'update' | 'delete' }) { return (
) }