Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreviewCell.tsx
Saxon Fletcher 1a013ea2c8 feat(studio): render assistant notebook runs (#49362)
<img width="1944" height="1053" alt="image"
src="https://github.com/user-attachments/assets/74c6968b-5ad4-46f7-adcc-a144221877b2"
/>


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

Assistant notebook-run UI, reusable result previews, and approval flow.

## Stack context

This stack is based on #49352 (`chore/assistant-tool-outcomes`) and
assumes #49350–#49352 merge first.

Review bottom to top:

1. #49361 — assistant notebook run tool
2. #49362 — assistant notebook run UI
3. #49364 — terminal-state polish

## What is the current behavior?

The `run_notebook` tool has no dedicated Assistant renderer, and the
shared notebook preview cannot display saved query results.

## What is the new behavior?

- Adds a run mode to the shared minified notebook preview.
- Adds a dedicated renderer for notebook-run tool parts and wires it
into `Message.Parts.tsx`.
- Loads the current notebook and presents all cells behind one **Run
notebook** approval.
- Renders database and Logs results inside their matching notebook cells
using the existing Explorer table/chart renderer and row-limit metadata.
- Gives cells with results separate bordered surfaces while preserving
the existing create/update layouts.
- Warns when the notebook changed before approval or since a historical
run.
- Preserves raw run results for the user while the model receives
separately sanitized output.
- Handles malformed input and notebook-loading failures without hiding
the approval state.

## How to test manually

This PR now contains both the reusable result preview and the
`tool-run_notebook` Assistant wiring, so it can be tested directly from
this branch. #49364 is not required for the notebook-run UI path.

1. Create and save a notebook with at least six cells. Include:
   - a markdown cell
   - a database query that returns rows
   - a query that returns no rows
   - a query that fails
   - a Logs query
   - a database query with a row limit
2. Ask the AI Assistant: **Read this notebook and analyze it using its
current results.**
3. Confirm the approval card displays the notebook name and current
cells, with one **Run notebook** button and one **Skip** action.
4. Click **Skip** and confirm the card remains visible with **Skipped
notebook run**.
5. Ask again and click **Run notebook**. Confirm the card enters a
running state, then shows **Notebook executed** with each result under
the cell that produced it.
6. Confirm the successful empty query says **Success. No rows returned**
and shows **0 rows**.
7. Confirm the failed query shows its error without hiding the other
cell results.
8. Confirm row counts and database row-limit copy appear below the
corresponding results.
9. Confirm only the first five cells are initially visible, then click
**Show more cells** and verify the remaining cells appear.
10. Refresh or reopen the conversation and confirm the completed
notebook preview and results remain visible.
11. Start another run but leave it awaiting approval. Edit and save the
notebook in another tab, then return and confirm the card warns
**Notebook changed since the Assistant read it**.
12. Complete a run, then edit and save the notebook. Reopen the
conversation and confirm the historical card warns **Notebook changed
since this run**.
13. Ask the Assistant to create or update a notebook and confirm those
proposal previews retain their grouped layout.

## Automated test

`mise exec node@22 -- pnpm --dir apps/studio exec vitest --run
components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx
components/ui/AIAssistantPanel/NotebookRunRenderer.test.tsx`

12 tests pass at this stack boundary.
2026-08-25 18:16:34 +10:00

205 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChevronRight, FileText, SquareCode } from 'lucide-react'
import {
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import {
getCellCodeBlockLanguage,
getCellLabel,
getCellMonacoLanguage,
getCellSourceText,
getEntryMetadataLine,
} from './AssistantNotebookPreview.utils'
import {
ExplorerQueryFooter,
ExplorerQueryResults,
} from '@/components/interfaces/Explorer/ExplorerQuery'
import { QueryResultRenderer } from '@/components/interfaces/Explorer/QueryEditor/QueryResultRenderer'
import type { QueryResult } from '@/components/interfaces/Explorer/types'
import { DiffEditor } from '@/components/ui/DiffEditor'
import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations'
import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema'
export interface AssistantNotebookPreviewCellProps {
entry: NotebookCellDiffEntry
isExpanded: boolean
onExpandedChange: (isExpanded: boolean) => void
/** Create and run previews omit per-row change glyphs. */
mode: 'create' | 'update' | 'run'
result?: QueryResult
}
/**
* A single-character gutter marker per change type The glyph is decorative —
* `changeLabel` carries the same information into the row's accessible name and its tooltip.
*/
const CHANGE_MARKERS = {
added: { glyph: '+', changeLabel: 'Added', className: 'text-brand-600' },
removed: { glyph: '−', changeLabel: 'Removed', className: 'text-destructive' },
replaced: { glyph: '~', changeLabel: 'Replaced', className: 'text-warning-600' },
moved: { glyph: '↕', changeLabel: 'Moved', className: 'text-foreground-lighter' },
unchanged: { glyph: '', changeLabel: 'Unchanged', className: '' },
} as const
type ChangeMarker = (typeof CHANGE_MARKERS)[keyof typeof CHANGE_MARKERS]
/** The cell a row is labelled by — for a replacement, the proposed cell rather than the current one. */
function getEntryCell(entry: NotebookCellDiffEntry): CellWire | AgentCell {
return entry._tag === 'replaced' ? entry.after : entry.cell
}
/** One row of the diff card: a header line that collapses to a single row, plus its content. */
export const AssistantNotebookPreviewCell = ({
entry,
isExpanded,
onExpandedChange,
mode,
result,
}: AssistantNotebookPreviewCellProps) => {
const marker = CHANGE_MARKERS[entry._tag]
const isRemoved = entry._tag === 'removed'
const cell = getEntryCell(entry)
const label = getCellLabel(cell)
const metadata = getEntryMetadataLine(entry)
return (
<Collapsible open={isExpanded} onOpenChange={onExpandedChange}>
<CollapsibleTrigger
aria-label={`${marker.changeLabel} ${label}`}
className="group flex w-full items-center gap-2 bg-muted px-3 py-2 text-left transition-colors hover:bg-accent"
>
{mode === 'update' && <ChangeGlyph marker={marker} />}
<ChevronRight
size={12}
className="shrink-0 text-foreground-lighter transition-transform group-data-[state=open]:rotate-90"
/>
<CellTypeIcon cell={cell} />
<span
className={cn(
'min-w-0 flex-1 truncate text-sm text-foreground',
isRemoved && 'text-foreground-lighter line-through'
)}
>
{label}
</span>
{metadata && (
<span className="min-w-0 max-w-[45%] shrink truncate text-sm text-muted-foreground">
{metadata}
</span>
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
<CellBody entry={entry} />
</div>
</CollapsibleContent>
{result !== undefined && cell._tag !== 'markdown_cell' && (
<QueryCellResult cell={cell} result={result} />
)}
</Collapsible>
)
}
const QueryCellResult = ({
cell,
result,
}: {
cell: Extract<CellWire | AgentCell, { _tag: 'database_cell' | 'log_cell' }>
result: QueryResult
}) => {
const rowCount = result.rows?.length ?? 0
const rowLimit = cell._tag === 'database_cell' ? cell.row_limit : undefined
return (
<>
<ExplorerQueryResults
className={cn(
'border-t',
rowCount === 0 ? 'min-h-20 items-center justify-center' : 'h-56 overflow-x-auto'
)}
>
<QueryResultRenderer view={cell.view ?? 'table'} result={result} chart={cell.chart} />
</ExplorerQueryResults>
<ExplorerQueryFooter className="flex items-center gap-x-2">
<p>
{rowCount.toLocaleString()} {rowCount === 1 ? 'row' : 'rows'}
</p>
{rowLimit !== undefined && (
<>
<p>·</p>
<p>{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}</p>
</>
)}
</ExplorerQueryFooter>
</>
)
}
/**
* The gutter glyph, with a tooltip naming the change type it stands for. The glyph stays
* `aria-hidden` — the row's `aria-label` already carries the same word — so the tooltip is a
* pointer affordance rather than a second announcement.
*/
const ChangeGlyph = ({ marker }: { marker: ChangeMarker }) => {
const className = cn('w-3 shrink-0 text-center font-mono text-xs leading-none', marker.className)
// An unchanged cell has no glyph, so there is nothing to explain — render the spacer alone.
if (!marker.glyph) return <span aria-hidden className={className} />
return (
<Tooltip>
<TooltipTrigger asChild>
<span aria-hidden className={className}>
{marker.glyph}
</span>
</TooltipTrigger>
<TooltipContent side="left">{marker.changeLabel}</TooltipContent>
</Tooltip>
)
}
/** Same icons the notebook tab uses for query vs markdown cells. */
const CellTypeIcon = ({ cell }: { cell: CellWire | AgentCell }) => {
const Icon = cell._tag === 'markdown_cell' ? FileText : SquareCode
return <Icon aria-hidden size={14} className="shrink-0 text-foreground-muted" />
}
const CellBody = ({ entry }: { entry: NotebookCellDiffEntry }) =>
entry._tag === 'replaced' ? (
<ReplacedCellBody before={entry.before} after={entry.after} />
) : (
<CellSource cell={entry.cell} />
)
const ReplacedCellBody = ({ before, after }: { before: CellWire; after: AgentCell }) => (
<DiffEditor
original={getCellSourceText(before)}
modified={getCellSourceText(after)}
language={getCellMonacoLanguage(after)}
height={240}
/>
)
/**
* The cell's source, always rendered as literal text via `CodeBlock` — agent-authored markdown
* must never be interpreted into real DOM nodes.
*/
const CellSource = ({ cell }: { cell: CellWire | AgentCell }) => (
<CodeBlock
hideCopy
hideLineNumbers
language={getCellCodeBlockLanguage(cell)}
value={getCellSourceText(cell)}
wrapLongLines
className="wrap-break-word rounded-none border-0 px-3! py-2! text-xs"
wrapperClassName="max-w-none"
/>
)