Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts
Charis e7d91dbd06 fix(studio): display diff for view-only notebook cell edits (#49901)
## Summary

Fixed a bug in the AI Assistant notebook-update proposal preview where a
`replace_cell` operation that only changed a cell's view (table ↔ chart)
or chart parameters (type, x/y columns, cumulative, scale, labels) would
show as a "Replaced" row but the expanded diff would appear empty.

**Root cause:** The diff editor only compared the cell's SQL text; view
and chart configuration were never considered, so changes to those
aspects showed no diff.

**Solution:**

* Refactored `getCellMetadata` to return structured `NotebookCellFields`
with separate `source` (database/time range) and `view` (table/chart)
fields instead of a single concatenated string
* Added `formatChartConfig` and `formatCellView` helpers to describe
chart cells
* Updated `getEntryMetadata` to diff source and view independently,
showing only the fields that actually changed (e.g., "Table → Chart
(bar, ...)" when only the view changed, with the unchanged database
omitted)
* If neither field changed, metadata is hidden entirely

## Test plan

* Added test cases for: chart-view cells reporting a `view` field,
view-only changes surfacing without the unchanged database,
chart-parameter-only changes surfacing without the unchanged database,
database-only changes surfacing without the unchanged view, and
fully-unchanged replacements hiding metadata entirely
* All 43 tests in the touched test file pass
* `tsc --noEmit` on apps/studio shows no new type errors

## Summary by CodeRabbit

* **Enhancements**
* Improved AI Assistant notebook previews with clearer cell details,
including source content and table or chart views.
* Chart previews now show key configuration details, such as chart type
and selected dimensions
* Replacement previews highlight only the fields that changed and hide
entries with no visible changes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Notebook previews now distinguish cell content from its view,
including table and chart details.
* Chart previews display relevant configuration, such as chart type and
axes.
  * Log previews include their formatted time range.
  * Replacement previews now show only the fields that changed.

* **Bug Fixes**
* Unchanged replacements are now hidden instead of displaying misleading
content.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-02 09:30:42 -04:00

475 lines
14 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 dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import {
formatNotebookDiffSummary,
formatTimeRange,
getCellLabel,
getCellMetadata,
getEntryKey,
getEntryMetadata,
notebookEntriesNeedDatabaseLookup,
resolveNotebookDatabaseTarget,
summarizeNotebookDiff,
type NotebookDatabaseContext,
} from './AssistantNotebookPreview.utils'
import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations'
import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema'
import { isoDateTimeString } from '@/lib/iso-datetime'
const wireMarkdownCell = (id: string, text = 'hello'): CellWire => ({
_tag: 'markdown_cell',
_id: id,
text,
})
const agentMarkdownCell = (text = 'hello'): AgentCell => ({ _tag: 'markdown_cell', text })
const wireDatabaseCell = (id: string, title?: string, database_identifier?: string): CellWire => ({
_tag: 'database_cell',
_id: id,
title,
sql: 'select 1',
row_limit: 100,
database_identifier,
})
const wireLogCell = (id: string): CellWire => ({
_tag: 'log_cell',
_id: id,
sql: 'select 1',
time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 },
})
const agentDatabaseCell = (database_identifier?: string): AgentCell => ({
_tag: 'database_cell',
sql: 'select 1',
row_limit: 100,
database_identifier,
})
const chartDatabaseCell = (id: string, ySeries: string[]): CellWire => ({
_tag: 'database_cell',
_id: id,
sql: 'select 1',
row_limit: 100,
view: 'chart',
chart: {
type: 'bar',
x_column: 'day',
y_series: ySeries,
cumulative: false,
scale: 'linear',
show_labels: false,
},
})
const agentChartDatabaseCell = (ySeries: string[], database_identifier?: string): AgentCell => ({
_tag: 'database_cell',
sql: 'select 1',
row_limit: 100,
database_identifier,
view: 'chart',
chart: {
type: 'bar',
x_column: 'day',
y_series: ySeries,
cumulative: false,
scale: 'linear',
show_labels: false,
},
})
const successfulDatabaseContext = (
databases: Array<{ identifier: string; region: string }> = []
): NotebookDatabaseContext => ({
status: 'success',
projectRef: 'project-1',
databasesByIdentifier: new Map(databases.map((database) => [database.identifier, database])),
})
describe('getEntryKey', () => {
it('keys unchanged, removed, and moved entries off the cell id', () => {
expect(getEntryKey({ _tag: 'unchanged', cell: wireMarkdownCell('cell-1') })).toBe('cell-1')
expect(
getEntryKey({ _tag: 'removed', cell: wireMarkdownCell('cell-2'), operationIndex: 0 })
).toBe('cell-2')
expect(
getEntryKey({
_tag: 'moved',
cell: wireMarkdownCell('cell-3'),
fromIndex: 1,
operationIndex: 0,
})
).toBe('cell-3')
})
it('keys added and replaced entries off the operation index', () => {
expect(getEntryKey({ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 2 })).toBe(
'op-2'
)
expect(
getEntryKey({
_tag: 'replaced',
before: wireMarkdownCell('cell-4'),
after: agentMarkdownCell(),
operationIndex: 3,
})
).toBe('op-3')
})
})
describe('getCellLabel', () => {
it('labels markdown cells', () => {
expect(getCellLabel(wireMarkdownCell('cell-1'))).toBe('Markdown cell')
})
it('labels query cells with their title', () => {
expect(getCellLabel(wireDatabaseCell('cell-1', 'Signups'))).toBe('Query: Signups')
})
it('falls back to "Untitled query" when a query cell has no title', () => {
expect(getCellLabel(wireDatabaseCell('cell-1'))).toBe('Query: Untitled query')
})
})
describe('formatTimeRange', () => {
it('formats a relative range, pluralizing the unit', () => {
expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'day', amount: 7 })).toBe(
'Last 7 days'
)
})
it('does not pluralize a relative range with amount 1', () => {
expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'hour', amount: 1 })).toBe(
'Last 1 hour'
)
})
it('formats an absolute range as a start → end pair', () => {
const start = isoDateTimeString('2026-01-01T13:00:00.000Z')!
const end = isoDateTimeString('2026-01-02T09:30:00.000Z')!
const formatted = formatTimeRange({ _tag: 'absolute_time_range', start, end })
// Bounds are asserted via dayjs rather than a hardcoded string so this doesn't depend on
// the test runner's local timezone (formatTimeRange formats in local time).
const expectedBound = (value: string) => dayjs(value).format('MMM D, YYYY h:mm A')
expect(formatted).toBe(`${expectedBound(start)}${expectedBound(end)}`)
})
})
describe('resolveNotebookDatabaseTarget', () => {
it('resolves an omitted identifier as primary without waiting for databases', () => {
expect(
resolveNotebookDatabaseTarget(undefined, { status: 'loading', projectRef: 'project-1' })
).toEqual({ status: 'primary' })
})
it('resolves the project ref as primary without waiting for databases', () => {
expect(
resolveNotebookDatabaseTarget('project-1', {
status: 'loading',
projectRef: 'project-1',
})
).toEqual({ status: 'primary' })
})
it('still resolves the loaded database matching the project ref as primary', () => {
expect(
resolveNotebookDatabaseTarget(
'project-1',
successfulDatabaseContext([{ identifier: 'project-1', region: 'us-east-1' }])
)
).toEqual({ status: 'primary' })
})
it('keeps another identifier loading until databases resolve', () => {
expect(
resolveNotebookDatabaseTarget('replica-3', {
status: 'loading',
projectRef: 'project-1',
})
).toEqual({ status: 'loading' })
})
it('resolves a matching loaded database as a replica', () => {
expect(
resolveNotebookDatabaseTarget(
'replica-3',
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({ status: 'replica' })
})
it('does not call an identifier absent from the loaded database list a replica', () => {
expect(resolveNotebookDatabaseTarget('replica-3', successfulDatabaseContext())).toEqual({
status: 'unknown',
})
})
it('preserves database lookup errors', () => {
expect(
resolveNotebookDatabaseTarget('replica-3', {
status: 'error',
projectRef: 'project-1',
})
).toEqual({ status: 'error' })
})
})
describe('notebookEntriesNeedDatabaseLookup', () => {
it('skips lookup for implicit and explicit primary cells', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireDatabaseCell('cell-1') },
{ _tag: 'unchanged', cell: wireDatabaseCell('cell-2', undefined, 'project-1') },
]
expect(notebookEntriesNeedDatabaseLookup(entries, 'project-1')).toBe(false)
})
it('requires lookup when either side of a replacement has another identifier', () => {
const entries: NotebookCellDiffEntry[] = [
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1'),
after: agentDatabaseCell('replica-3'),
operationIndex: 0,
},
]
expect(notebookEntriesNeedDatabaseLookup(entries, 'project-1')).toBe(true)
})
})
describe('getCellMetadata', () => {
it('hides metadata for markdown cells', () => {
expect(getCellMetadata(wireMarkdownCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'hidden',
})
})
it('labels an implicit primary database and table view', () => {
expect(getCellMetadata(wireDatabaseCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'ready',
source: 'Database: Primary',
view: 'Table',
})
})
it('labels a resolved replica descriptively', () => {
expect(
getCellMetadata(
wireDatabaseCell('cell-1', 'Signups', 'replica-3'),
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({
status: 'ready',
source: 'Database: Replica',
view: 'Table',
})
})
it('returns a loading state rather than guessing another identifier is a replica', () => {
expect(
getCellMetadata(wireDatabaseCell('cell-1', 'Signups', 'replica-3'), {
status: 'loading',
projectRef: 'project-1',
})
).toEqual({ status: 'loading' })
})
it('labels a missing loaded identifier as unknown', () => {
expect(
getCellMetadata(
wireDatabaseCell('cell-1', 'Signups', 'missing-database'),
successfulDatabaseContext()
)
).toEqual({ status: 'ready', source: 'Database: Unknown', view: 'Table' })
})
it('labels a log cell with its formatted time range', () => {
expect(getCellMetadata(wireLogCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'ready',
source: 'Time range: Last 7 days',
view: 'Table',
})
})
it('reports the chart summary as the view field for a chart-view database cell', () => {
expect(
getCellMetadata(chartDatabaseCell('cell-1', ['signups']), successfulDatabaseContext())
).toEqual({
status: 'ready',
source: 'Database: Primary',
view: 'Chart (bar, x: day, y: signups)',
})
})
})
describe('getEntryMetadata', () => {
it('uses the cell metadata for non-replaced entries', () => {
expect(
getEntryMetadata(
{
_tag: 'unchanged',
cell: wireDatabaseCell('cell-1', 'Signups', 'replica-3'),
},
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({
status: 'ready',
text: 'Database: Replica',
})
})
it('returns a before → after pair when a replacement changes only metadata', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1', 'Signups'),
after: agentDatabaseCell('replica-3'),
operationIndex: 0,
},
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({
status: 'ready',
text: 'Database: Primary → Database: Replica',
})
})
it('returns a loading state when either side still needs database data', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1', 'Signups'),
after: agentDatabaseCell('replica-3'),
operationIndex: 0,
},
{ status: 'loading', projectRef: 'project-1' }
)
).toEqual({ status: 'loading' })
})
it('hides metadata entirely when a replacement changes neither the database nor the view', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1', 'Signups'),
after: agentDatabaseCell(),
operationIndex: 0,
},
successfulDatabaseContext()
)
).toEqual({ status: 'hidden' })
})
it('surfaces only the view change from table to chart, omitting the unchanged database', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1'),
after: agentChartDatabaseCell(['signups']),
operationIndex: 0,
},
successfulDatabaseContext()
)
).toEqual({
status: 'ready',
text: 'Table → Chart (bar, x: day, y: signups)',
})
})
it('surfaces only a chart parameter change, omitting the unchanged database', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: chartDatabaseCell('cell-1', ['signups']),
after: agentChartDatabaseCell(['active_users']),
operationIndex: 0,
},
successfulDatabaseContext()
)
).toEqual({
status: 'ready',
text: 'Chart (bar, x: day, y: signups) → Chart (bar, x: day, y: active_users)',
})
})
it('surfaces only a database change, omitting the unchanged view', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: chartDatabaseCell('cell-1', ['signups']),
after: agentChartDatabaseCell(['signups'], 'replica-3'),
operationIndex: 0,
},
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({
status: 'ready',
text: 'Database: Primary → Database: Replica',
})
})
})
describe('summarizeNotebookDiff / formatNotebookDiffSummary', () => {
it('formats create mode with pluralized cell count', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'unchanged', cell: wireMarkdownCell('b') },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('2 cells')
})
it('formats create mode singular for a single cell', () => {
const entries: NotebookCellDiffEntry[] = [{ _tag: 'unchanged', cell: wireMarkdownCell('a') }]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('1 cell')
})
it('formats update mode with a mix of categories, ignoring unchanged', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 },
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 1 },
{ _tag: 'removed', cell: wireMarkdownCell('d'), operationIndex: 2 },
{
_tag: 'replaced',
before: wireMarkdownCell('e'),
after: agentMarkdownCell(),
operationIndex: 3,
},
{ _tag: 'moved', cell: wireMarkdownCell('f'), fromIndex: 0, operationIndex: 4 },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+2 1 ~1 ↕1')
})
it('omits zero categories from the update summary', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+1')
})
it('falls back to "No changes" when every entry is unchanged', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'unchanged', cell: wireMarkdownCell('b') },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('No changes')
})
})