explorer prototype

This commit is contained in:
Saxon Fletcher committed 2026-08-04 11:59:46 +10:00
1 parent 1123a74813
commit 0eeef6982f
22 files changed
+3073

No files matched your search

+1
View File
@@ -314,6 +314,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
- [x] A `routes/project/$ref/sql/$id.tsx` ← `pages/project/[ref]/sql/[id].tsx`
- [x] A `routes/project/$ref/sql/templates.tsx` ← `pages/project/[ref]/sql/templates.tsx`
- [x] A `routes/project/$ref/sql/quickstarts.tsx` ← `pages/project/[ref]/sql/quickstarts.tsx`
- [ ] `routes/project/$ref/explorer-prototype.tsx` ← `pages/project/[ref]/explorer-prototype.tsx` — **intentionally deferred.** Throwaway Explorer/Notebooks reference prototype; it runs on the Next runtime (the default) only. Adding the mirror requires `routeTree.gen.ts` to be regenerated by the Vite plugin, so create the route while running `vite dev` if you need it under TanStack. Delete this entry and the page together when the real Explorer lands.
### Project shell — `/editor/*`
@@ -0,0 +1,299 @@
/**
* PROTOTYPE — seed data and a fake query runner.
*
* `runMockQuery` stands in for PR E2's `useQuerySourceRun`. It has the same
* shape as the real thing (source in, rows out, latency, occasional failure)
* so the surfaces around it are wired the way they will be for real.
*/
import type {
CellSource,
ChatSession,
NotebookContent,
RecentItem,
ResultRow,
SnippetDoc,
Source,
Tab,
} from './ExplorerPrototype.types'
/** Sources are declared by the application, once. Cells only reference them by id. */
export const SOURCES: Source[] = [
{ id: 'database', type: 'database', endpoint: '/platform/pg-meta/{ref}/query' },
{
id: 'logs',
type: 'logs',
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all.otel',
},
]
export const MOCK_REPLICAS = [
{ identifier: 'primary', label: 'Primary database' },
{ identifier: 'replica-eu-west-1', label: 'Read replica — eu-west-1' },
{ identifier: 'replica-us-east-1', label: 'Read replica — us-east-1' },
]
// ---------------------------------------------------------------------------
// Seed documents
// ---------------------------------------------------------------------------
export const INITIAL_NOTEBOOKS: Record<string, NotebookContent> = {
'nb-auth-health': {
schema_version: 1,
settings: { run_mode: 'manual', default_row_limit: 100 },
cells: [
{
id: 'cell-heading',
type: 'markdown',
markdown:
'## Authentication health\n\nRun the cells below to check signup volume and recent auth failures. Cells run top to bottom.',
},
{
id: 'cell-signups',
type: 'query',
name: 'Signups by day',
query: {
type: 'inline',
source: { id: 'database', parameters: { identifier: 'primary' } },
sql: "select date_trunc('day', created_at)::date as day,\n count(*) as signups,\n count(*) filter (where email_confirmed_at is not null) as confirmed\nfrom auth.users\ngroup by 1\norder by 1",
},
display: {
type: 'chart',
chart: {
type: 'line',
x_axis: { field: 'day' },
series: [
{ field: 'signups', label: 'Signups' },
{ field: 'confirmed', label: 'Confirmed' },
],
},
},
},
{
id: 'cell-auth-errors',
type: 'query',
name: 'Recent authentication errors',
query: {
type: 'inline',
source: {
id: 'logs',
parameters: { time_range: { type: 'relative', amount: 24, unit: 'hour' } },
},
sql: "select timestamp, event_message\nfrom logs\nwhere event_message ilike '%error%'\norder by timestamp desc",
},
display: { type: 'table' },
execution: { row_limit: 50 },
},
{
id: 'cell-active-users',
type: 'query',
name: 'Active database users',
query: {
type: 'inline',
source: { id: 'database', parameters: { identifier: 'primary' } },
sql: 'select id, email, created_at\nfrom auth.users\nwhere deleted_at is null\norder by created_at desc',
},
display: { type: 'table' },
},
],
},
}
export const INITIAL_SNIPPETS: Record<string, SnippetDoc> = {
'snip-slow-queries': {
id: 'snip-slow-queries',
name: 'Slowest queries',
contentType: 'sql',
sql: 'select calls, mean_exec_time, query\nfrom pg_stat_statements\norder by mean_exec_time desc\nlimit 20',
display: { type: 'table' },
},
'snip-edge-errors': {
id: 'snip-edge-errors',
name: 'Edge errors by hour',
contentType: 'log_sql',
sql: 'select toStartOfHour(timestamp) as hour, count(*) as errors\nfrom logs\nwhere status_code >= 500\ngroup by hour\norder by hour',
display: {
type: 'chart',
chart: {
type: 'bar',
x_axis: { field: 'hour' },
series: [{ field: 'errors', label: 'Errors' }],
},
},
},
}
export const INITIAL_CHATS: Record<string, ChatSession> = {
'chat-1': {
id: 'chat-1',
name: 'Debugging signups',
messages: [
{ id: 'm1', role: 'user', text: 'Why did signups drop off last week?' },
{
id: 'm2',
role: 'assistant',
text: "Let's start by looking at daily signups alongside confirmation rate. I can run this for you:",
},
{
id: 'm3',
role: 'assistant',
approval: 'pending',
cell: {
id: 'agent-cell-1',
type: 'query',
name: 'Signups vs confirmations, last 14 days',
query: {
type: 'inline',
source: { id: 'database', parameters: { identifier: 'primary' } },
sql: "select date_trunc('day', created_at)::date as day,\n count(*) as signups,\n count(*) filter (where email_confirmed_at is not null) as confirmed\nfrom auth.users\nwhere created_at > now() - interval '14 days'\ngroup by 1\norder by 1",
},
display: {
type: 'chart',
chart: {
type: 'bar',
x_axis: { field: 'day' },
series: [
{ field: 'signups', label: 'Signups' },
{ field: 'confirmed', label: 'Confirmed' },
],
},
},
},
},
],
},
}
export const INITIAL_TABS: Tab[] = [
{
id: 'tab-nb',
title: 'Authentication health',
resource: { type: 'notebook', id: 'nb-auth-health' },
},
{
id: 'tab-snip',
title: 'Slowest queries',
resource: { type: 'snippet', id: 'snip-slow-queries' },
},
{ id: 'tab-chat', title: 'Debugging signups', resource: { type: 'chat', id: 'chat-1' } },
]
/** Seeded so the "Recent" group isn't empty on first load. Mixed across types. */
export const INITIAL_RECENT_ITEMS: RecentItem[] = [
{
resource: { type: 'notebook', id: 'nb-auth-health' },
modifiedAt: Date.now() - 4 * 60_000,
},
{
resource: { type: 'chat', id: 'chat-1' },
modifiedAt: Date.now() - 26 * 60_000,
},
{
resource: { type: 'snippet', id: 'snip-slow-queries' },
modifiedAt: Date.now() - 3 * 3_600_000,
},
{
resource: { type: 'snippet', id: 'snip-edge-errors' },
modifiedAt: Date.now() - 2 * 86_400_000,
},
]
// ---------------------------------------------------------------------------
// Fake execution
// ---------------------------------------------------------------------------
const DAYS = 14
const buildSignupRows = (): ResultRow[] =>
Array.from({ length: DAYS }, (_, index) => {
const date = new Date(Date.now() - (DAYS - 1 - index) * 86_400_000)
const signups = Math.round(120 + Math.sin(index / 2) * 40 + index * 4)
return {
day: date.toISOString().slice(0, 10),
signups,
confirmed: Math.round(signups * (0.62 + (index % 3) * 0.05)),
}
})
const buildLogRows = (): ResultRow[] =>
Array.from({ length: 24 }, (_, index) => {
const stamp = new Date(Date.now() - index * 3_600_000)
return {
timestamp: stamp.toISOString(),
event_message: [
'AuthApiError: Invalid login credentials',
'AuthApiError: Email rate limit exceeded',
'error: token has expired or is invalid',
][index % 3],
}
})
const buildHourlyErrorRows = (): ResultRow[] =>
Array.from({ length: 24 }, (_, index) => {
const stamp = new Date(Date.now() - (23 - index) * 3_600_000)
return {
hour: stamp.toISOString().slice(0, 13).replace('T', ' ') + ':00',
errors: Math.max(0, Math.round(18 + Math.sin(index / 3) * 12 - (index % 5))),
}
})
const buildUserRows = (): ResultRow[] =>
Array.from({ length: 40 }, (_, index) => ({
id: `8f14e45f-ceea-467a-9f1a-${String(index).padStart(12, '0')}`,
email: `user${index + 1}@example.com`,
created_at: new Date(Date.now() - index * 5_400_000).toISOString(),
}))
const buildSlowQueryRows = (): ResultRow[] => [
{ calls: 12_408, mean_exec_time: 812.4, query: 'select * from orders where customer_id = $1' },
{
calls: 3_201,
mean_exec_time: 431.9,
query: 'select count(*) from events where created_at > $1',
},
{
calls: 890,
mean_exec_time: 288.1,
query: 'update inventory set stock = stock - $1 where id = $2',
},
{ calls: 22_910, mean_exec_time: 96.3, query: 'select id, email from auth.users where id = $1' },
]
export const isWriteQuery = (sql: string) =>
/^\s*(insert|update|delete|drop|alter|truncate|create)\b/i.test(sql)
export type MockRunOutcome =
| { status: 'success'; rows: ResultRow[] }
| { status: 'error'; message: string }
/** Picks canned rows by keyword. Stands in for a real endpoint call. */
export const runMockQuery = async ({
source,
sql,
rowLimit,
}: {
source: CellSource
sql: string
rowLimit: number
}): Promise<MockRunOutcome> => {
await new Promise((resolve) => setTimeout(resolve, 420))
const normalized = sql.toLowerCase()
if (normalized.includes('nonexistent_table')) {
return { status: 'error', message: 'relation "nonexistent_table" does not exist' }
}
if (source.id === 'logs') {
const rows = normalized.includes('count(') ? buildHourlyErrorRows() : buildLogRows()
return { status: 'success', rows: rows.slice(0, rowLimit) }
}
if (normalized.includes('pg_stat_statements')) {
return { status: 'success', rows: buildSlowQueryRows().slice(0, rowLimit) }
}
if (normalized.includes('group by')) {
return { status: 'success', rows: buildSignupRows().slice(0, rowLimit) }
}
return { status: 'success', rows: buildUserRows().slice(0, rowLimit) }
}
@@ -0,0 +1,394 @@
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import {
INITIAL_CHATS,
INITIAL_NOTEBOOKS,
INITIAL_RECENT_ITEMS,
INITIAL_SNIPPETS,
isWriteQuery,
runMockQuery,
} from './ExplorerPrototype.mocks'
import type {
CellResultState,
QueryCellModel,
RecentItem,
SnippetDoc,
} from './ExplorerPrototype.types'
import { ExplorerSidebar } from './ExplorerSidebar'
import { applyCumulative } from './QueryCellDisplay'
import {
resolveEffectiveRowLimit,
resolveEffectiveRunMode,
useExplorerPrototypeState,
} from './useExplorerPrototypeState'
import { ChatView } from './views/ChatView'
import { queryCellToSnippet, snippetToQueryCell } from './views/snippetAdapter'
import { customRender, customRenderHook } from '@/tests/lib/custom-render'
describe('snippet adapter', () => {
const snippet: SnippetDoc = {
id: 'snip-1',
name: 'Slowest queries',
contentType: 'sql',
sql: 'select 1',
display: { type: 'table' },
}
it('maps a database snippet to a one-cell query model and back unchanged', () => {
const cell = snippetToQueryCell(snippet)
expect(cell.type).toBe('query')
expect(cell.query.type).toBe('inline')
expect(cell.query.source.id).toBe('database')
expect(queryCellToSnippet(cell, snippet)).toEqual(snippet)
})
it('maps a logs snippet onto the logs source with a default time range', () => {
const logsSnippet: SnippetDoc = { ...snippet, contentType: 'log_sql' }
const cell = snippetToQueryCell(logsSnippet)
expect(cell.query.source.id).toBe('logs')
expect(queryCellToSnippet(cell, logsSnippet)).toEqual(logsSnippet)
})
it('writes the content type back from the chosen source', () => {
const cell = snippetToQueryCell(snippet)
const switched: QueryCellModel = {
...cell,
query: {
...cell.query,
source: {
id: 'logs',
parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } },
},
},
}
expect(queryCellToSnippet(switched, snippet).contentType).toBe('log_sql')
})
})
describe('run mode and row limit resolution', () => {
const settings = { run_mode: 'manual', default_row_limit: 100 } as const
const cell: QueryCellModel = {
id: 'cell-1',
type: 'query',
name: 'Test',
query: {
type: 'inline',
source: { id: 'database', parameters: {} },
sql: 'select 1',
},
display: { type: 'table' },
}
it('falls back to the notebook settings when the cell has no override', () => {
expect(resolveEffectiveRunMode(cell, settings)).toBe('manual')
expect(resolveEffectiveRowLimit(cell, settings)).toBe(100)
})
it('prefers the cell override', () => {
const overridden: QueryCellModel = {
...cell,
execution: { run_mode: 'on_open', row_limit: 50 },
}
expect(resolveEffectiveRunMode(overridden, settings)).toBe('on_open')
expect(resolveEffectiveRowLimit(overridden, settings)).toBe(50)
})
})
describe('write query detection', () => {
it.each([
['select * from users', false],
[' select 1', false],
['insert into users values (1)', true],
['UPDATE users set name = 1', true],
['delete from users', true],
['drop table users', true],
])('%s → isWrite %s', (sql, expected) => {
expect(isWriteQuery(sql)).toBe(expected)
})
})
describe('cumulative display transform', () => {
it('accumulates each series independently', () => {
const rows = [
{ day: '1', a: 1, b: 10 },
{ day: '2', a: 2, b: 20 },
{ day: '3', a: 3, b: 30 },
]
expect(applyCumulative(rows, ['a', 'b'])).toEqual([
{ day: '1', a: 1, b: 10 },
{ day: '2', a: 3, b: 30 },
{ day: '3', a: 6, b: 60 },
])
})
})
describe('mock query runner honors the source and row limit', () => {
it('limits returned rows', async () => {
const outcome = await runMockQuery({
source: { id: 'database', parameters: { identifier: 'primary' } },
sql: 'select id, email from auth.users',
rowLimit: 5,
})
expect(outcome.status).toBe('success')
if (outcome.status === 'success') expect(outcome.rows).toHaveLength(5)
})
it('returns log-shaped rows for the logs source', async () => {
const outcome = await runMockQuery({
source: {
id: 'logs',
parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } },
},
sql: 'select timestamp, event_message from logs',
rowLimit: 3,
})
expect(outcome.status).toBe('success')
if (outcome.status === 'success') {
expect(Object.keys(outcome.rows[0])).toEqual(['timestamp', 'event_message'])
}
})
})
/**
* The chat surface renders the shared QueryCell read-only, so this also proves
* the component mounts and the approval-gated run path produces results.
*/
const ChatHarness = () => {
const [chat, setChat] = useState(INITIAL_CHATS['chat-1'])
const [results, setResults] = useState<Record<string, CellResultState>>({})
const approve = async (messageId: string, cell: QueryCellModel) => {
setChat((current) => ({
...current,
messages: current.messages.map((message) =>
message.id === messageId && 'approval' in message
? { ...message, approval: 'approved' as const }
: message
),
}))
setResults((current) => ({ ...current, [cell.id]: { status: 'running' } }))
const outcome = await runMockQuery({
source: cell.query.source,
sql: cell.query.sql,
rowLimit: 100,
})
setResults((current) => ({
...current,
[cell.id]:
outcome.status === 'success'
? { status: 'success', rows: outcome.rows, ranAt: '', rowLimitApplied: 100 }
: { status: 'error', message: outcome.message },
}))
}
const deny = (messageId: string) =>
setChat((current) => ({
...current,
messages: current.messages.map((message) =>
message.id === messageId && 'approval' in message
? { ...message, approval: 'denied' as const }
: message
),
}))
return <ChatView chat={chat} results={results} onApprove={approve} onDeny={deny} />
}
describe('agent chat surface', () => {
it('renders the agent query block with an approval prompt and no editable SQL', async () => {
customRender(<ChatHarness />)
expect(await screen.findByText('The Assistant wants to run this query.')).toBeInTheDocument()
expect(screen.getByText('Signups vs confirmations, last 14 days')).toBeInTheDocument()
// Read-only surface: the cell name is text, not an input.
expect(screen.queryByLabelText('Cell name')).not.toBeInTheDocument()
})
it('runs the query once approved and reports the row count', async () => {
const user = userEvent.setup()
customRender(<ChatHarness />)
await user.click(await screen.findByRole('button', { name: 'Run query' }))
await waitFor(() => expect(screen.getByText(/14 rows · limit 100/)).toBeInTheDocument(), {
timeout: 3000,
})
expect(
screen.getByText(/data sharing is off, so rows are not sent to the Assistant/)
).toBeInTheDocument()
})
it('does not run the query when skipped', async () => {
const user = userEvent.setup()
customRender(<ChatHarness />)
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(await screen.findByText('Skipped — the query was not run.')).toBeInTheDocument()
expect(screen.queryByText(/rows · limit/)).not.toBeInTheDocument()
})
})
/**
* Both drill-down levels stay mounted inside the sliding track, so these assert
* through role queries — the off-screen level is `aria-hidden` and therefore
* absent from the accessibility tree that `getByRole` searches.
*/
describe('sidebar drill-down', () => {
const renderSidebar = (onOpen = vi.fn()) => {
customRender(
<ExplorerSidebar
notebooks={INITIAL_NOTEBOOKS}
snippets={INITIAL_SNIPPETS}
chats={INITIAL_CHATS}
recentItems={INITIAL_RECENT_ITEMS}
onOpen={onOpen}
/>
)
return { onOpen }
}
const rootPanel = () => within(screen.getByRole('group', { name: 'All resources' }))
it('shows the three resource types and a mixed recent group at root', () => {
renderSidebar()
const root = rootPanel()
expect(root.getByRole('button', { name: /Snippets/ })).toBeInTheDocument()
expect(root.getByRole('button', { name: /Notebooks/ })).toBeInTheDocument()
expect(root.getByRole('button', { name: /Chats/ })).toBeInTheDocument()
expect(root.getByText('Recent')).toBeInTheDocument()
// Recent mixes types: a notebook, a chat and a snippet all appear.
expect(root.getByRole('button', { name: /Authentication health/ })).toBeInTheDocument()
expect(root.getByRole('button', { name: /Debugging signups/ })).toBeInTheDocument()
expect(root.getByRole('button', { name: /Slowest queries/ })).toBeInTheDocument()
// The drilled level exists in the DOM but is out of the a11y tree.
expect(screen.queryByRole('group', { name: 'Snippets' })).not.toBeInTheDocument()
})
it('drills into a type, showing its list with search and a back button', async () => {
const user = userEvent.setup()
renderSidebar()
await user.click(rootPanel().getByRole('button', { name: /Snippets/ }))
const panel = within(await screen.findByRole('group', { name: 'Snippets' }))
expect(panel.getByRole('button', { name: 'Back to all resources' })).toBeInTheDocument()
expect(panel.getByPlaceholderText('Search snippets')).toBeInTheDocument()
expect(panel.getByRole('button', { name: /Slowest queries/ })).toBeInTheDocument()
expect(panel.getByRole('button', { name: /Edge errors by hour/ })).toBeInTheDocument()
// Root is now the hidden level.
expect(screen.queryByRole('group', { name: 'All resources' })).not.toBeInTheDocument()
})
it('filters the drilled list by search', async () => {
const user = userEvent.setup()
renderSidebar()
await user.click(rootPanel().getByRole('button', { name: /Snippets/ }))
const panel = within(await screen.findByRole('group', { name: 'Snippets' }))
await user.type(panel.getByPlaceholderText('Search snippets'), 'edge')
await waitFor(() =>
expect(panel.queryByRole('button', { name: /Slowest queries/ })).not.toBeInTheDocument()
)
expect(panel.getByRole('button', { name: /Edge errors by hour/ })).toBeInTheDocument()
})
it('reports when a search matches nothing', async () => {
const user = userEvent.setup()
renderSidebar()
await user.click(rootPanel().getByRole('button', { name: /Notebooks/ }))
const panel = within(await screen.findByRole('group', { name: 'Notebooks' }))
await user.type(panel.getByPlaceholderText('Search notebooks'), 'zzz')
expect(await panel.findByText(/No notebooks match "zzz"/)).toBeInTheDocument()
})
it('returns to root via the back button and clears the search', async () => {
const user = userEvent.setup()
renderSidebar()
await user.click(rootPanel().getByRole('button', { name: /Chats/ }))
const panel = within(await screen.findByRole('group', { name: 'Chats' }))
await user.type(panel.getByPlaceholderText('Search chats'), 'debug')
await user.click(panel.getByRole('button', { name: 'Back to all resources' }))
expect(await screen.findByRole('group', { name: 'All resources' })).toBeInTheDocument()
// Re-entering starts with an empty search, not the previous term.
await user.click(rootPanel().getByRole('button', { name: /Chats/ }))
const reopened = within(await screen.findByRole('group', { name: 'Chats' }))
expect(reopened.getByPlaceholderText('Search chats')).toHaveValue('')
})
it('opens the chosen resource with its type and id', async () => {
const user = userEvent.setup()
const { onOpen } = renderSidebar()
await user.click(rootPanel().getByRole('button', { name: /Notebooks/ }))
const panel = within(await screen.findByRole('group', { name: 'Notebooks' }))
await user.click(panel.getByRole('button', { name: /Authentication health/ }))
expect(onOpen).toHaveBeenCalledWith(
{ type: 'notebook', id: 'nb-auth-health' },
'Authentication health'
)
})
})
describe('recent items track modification, not navigation', () => {
it('does not reorder when a tab is merely opened', () => {
const { result } = customRenderHook(() => useExplorerPrototypeState())
const before = result.current.recentItems
act(() => result.current.openTab({ type: 'snippet', id: 'snip-edge-errors' }, 'Edge errors'))
expect(result.current.recentItems).toEqual(before)
})
it('moves a resource to the top when it is edited', () => {
const { result } = customRenderHook(() => useExplorerPrototypeState())
expect(result.current.recentItems[0].resource.id).not.toBe('snip-edge-errors')
act(() =>
result.current.updateSnippet('snip-edge-errors', {
...INITIAL_SNIPPETS['snip-edge-errors'],
sql: 'select 1',
})
)
expect(result.current.recentItems[0].resource).toEqual({
type: 'snippet',
id: 'snip-edge-errors',
})
})
it('does not duplicate a resource that is edited twice', () => {
const { result } = customRenderHook(() => useExplorerPrototypeState())
const notebookId = 'nb-auth-health'
act(() => result.current.addCell(notebookId, 'markdown'))
act(() => result.current.addCell(notebookId, 'markdown'))
const matching = (result.current.recentItems as RecentItem[]).filter(
(item) => item.resource.id === notebookId && item.resource.type === 'notebook'
)
expect(matching).toHaveLength(1)
expect(result.current.recentItems[0].resource.id).toBe(notebookId)
})
})
@@ -0,0 +1,108 @@
/**
* PROTOTYPE — the Explorer content area.
*
* A tab strip plus a renderer that switches on `tab.resource.type`. This is the
* top of the design diagram: the shell knows about resource *types*, and nothing
* else about what a resource contains.
*
* It sits inside the same project chrome as the SQL editor — `DefaultLayout` →
* `ProjectLayoutWithAuth` (see the page's `getLayout`) — and fills the same
* slots: the sidebar goes in `productMenu`, this goes in `children`. The inner
* markup deliberately mirrors `EditorBaseLayout` so the tab strip lines up with
* the real editor's.
*
* Reference only — mock data, fake execution, throwaway state.
*/
import { Admonition } from 'ui-patterns/Admonition'
import type { Tab } from './ExplorerPrototype.types'
import { useExplorerPrototype } from './ExplorerPrototypeContext'
import { ExplorerTabBar } from './ExplorerTabBar'
import { ChatView } from './views/ChatView'
import { NotebookView } from './views/NotebookView'
import { snippetToQueryCell } from './views/snippetAdapter'
import { SnippetView } from './views/SnippetView'
export const ExplorerPrototype = () => {
const state = useExplorerPrototype()
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
const renderTab = (tab: Tab) => {
if (tab.resource.type === 'snippet') {
const snippet = state.snippets[tab.resource.id]
if (!snippet) return null
return (
<SnippetView
snippet={snippet}
result={state.results[snippet.id] ?? { status: 'idle' }}
onChange={(next) => state.updateSnippet(snippet.id, next)}
onRun={(rowLimit) => state.runCell(snippetToQueryCell(snippet), rowLimit)}
/>
)
}
if (tab.resource.type === 'notebook') {
const notebookId = tab.resource.id
const notebook = state.notebooks[notebookId]
if (!notebook) return null
return (
<NotebookView
title={tab.title}
notebook={notebook}
results={state.results}
onCellChange={(cellId, cell) => state.updateCell(notebookId, cellId, cell)}
onAddCell={(type, afterCellId) => state.addCell(notebookId, type, afterCellId)}
onRemoveCell={(cellId) => state.removeCell(notebookId, cellId)}
onMoveCell={(cellId, direction) => state.moveCell(notebookId, cellId, direction)}
onSettingsChange={(settings) => state.updateNotebookSettings(notebookId, settings)}
onRunCell={(cell, rowLimit) => state.runCell(cell, rowLimit)}
onRunAll={() => state.runNotebook(notebookId)}
/>
)
}
const chat = state.chats[tab.resource.id]
if (!chat) return null
return (
<ChatView
chat={chat}
results={state.results}
onApprove={(messageId, cell) => {
state.setChatApproval(chat.id, messageId, 'approved')
state.runCell(cell, 100)
}}
onDeny={(messageId) => state.setChatApproval(chat.id, messageId, 'denied')}
/>
)
}
return (
// `bg-background` is the page surface the active tab blends into.
<div className="flex h-full flex-col bg-background">
<div className="flex h-10 items-center border-b bg-muted md:min-h-(--header-height)">
<ExplorerTabBar
tabs={state.tabs}
activeTabId={state.activeTabId}
dirtyResources={state.dirtyResources}
onSelect={state.setActiveTabId}
onClose={state.closeTab}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{activeTab ? (
renderTab(activeTab)
) : (
<div className="mx-auto max-w-lg p-10">
<Admonition
type="default"
title="No tabs open"
description="Choose a notebook, snippet, or chat from the sidebar."
/>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,161 @@
/**
* PROTOTYPE — reference implementation of the Explorer / Notebooks schema.
*
* This file is the schema from the design spec, expressed as TypeScript. It is
* deliberately standalone: nothing here imports from `state/` or `data/`, so the
* shapes can be reviewed on their own before PR N1 turns them into zod schemas.
*
* Two deliberate departures from the spec text:
*
* 1. `QueryBinding` is inline-only. `SnippetReference` was dropped — Notebooks
* ship as a new feature, so there are no legacy cells to stay compatible with.
* 2. `CellSource` is a discriminated union rather than `{ id, parameters }` with
* loose parameter typing. Same wire shape, but the compiler can then tell that
* `time_range` belongs to logs and `identifier` belongs to database.
*/
// ---------------------------------------------------------------------------
// Tabs — a tab points at any Explorer resource. Chat internals stay outside.
// ---------------------------------------------------------------------------
export type TabResource =
| { type: 'snippet'; id: string }
| { type: 'notebook'; id: string }
| { type: 'chat'; id: string }
export type Tab = {
id: string
title: string
resource: TabResource
}
/**
* Sidebar "Recent" group — mixed across every resource type, ordered by last
* *modification*, not by when a tab was opened. Titles are deliberately not
* stored: they're derived from the live documents so a rename shows up here.
*/
export type RecentItem = {
resource: TabResource
modifiedAt: number
}
// ---------------------------------------------------------------------------
// Sources — application-wide. A source owns its endpoint and its parameters.
// ---------------------------------------------------------------------------
export type SourceId = 'database' | 'logs'
export type LogTimeRange =
| { type: 'relative'; amount: number; unit: 'minute' | 'hour' | 'day' | 'week' }
| { type: 'absolute'; from: string; to: string }
/** What the application declares once, at startup. */
export type Source =
| { id: 'database'; type: 'database'; endpoint: string }
| { id: 'logs'; type: 'logs'; endpoint: string }
/** What a cell stores: which source, plus the parameter values to send to it. */
export type CellSource =
| { id: 'database'; parameters: { identifier?: string } }
| { id: 'logs'; parameters: { time_range: LogTimeRange } }
// ---------------------------------------------------------------------------
// Display — the SELECT determines the columns; this only says how to draw them.
// ---------------------------------------------------------------------------
export type ChartDisplay = {
type: 'bar' | 'line'
x_axis: { field: string }
series: Array<{ field: string; label?: string }>
cumulative?: boolean
show_labels?: boolean
log_scale?: boolean
}
export type QueryDisplay = { type: 'table' } | { type: 'chart'; chart: ChartDisplay }
// ---------------------------------------------------------------------------
// Cells
// ---------------------------------------------------------------------------
export type RunMode = 'manual' | 'on_open'
export type InlineQuery = {
type: 'inline'
source: CellSource
sql: string
}
export type QueryCellModel = {
id: string
type: 'query'
name: string
query: InlineQuery
display: QueryDisplay
execution?: {
run_mode?: RunMode
row_limit?: number
}
}
export type MarkdownCell = {
id: string
type: 'markdown'
markdown: string
}
export type NotebookCell = QueryCellModel | MarkdownCell
export type NotebookContent = {
schema_version: 1
settings: {
run_mode: RunMode
default_row_limit: number
}
/** Array position defines display and execution order. */
cells: NotebookCell[]
}
// ---------------------------------------------------------------------------
// Execution result — caller-owned state, so each surface decides where it lives
// (notebook session state, assistant message state, snippet session state).
// ---------------------------------------------------------------------------
export type ResultRow = Record<string, string | number | null>
export type CellResultState =
| { status: 'idle' }
| { status: 'running' }
| { status: 'success'; rows: ResultRow[]; ranAt: string; rowLimitApplied: number }
| { status: 'error'; message: string }
// ---------------------------------------------------------------------------
// Surfaces other than notebooks
// ---------------------------------------------------------------------------
/** A saved snippet. The snippet adapter maps this to and from a one-cell QueryCellModel. */
export type SnippetDoc = {
id: string
name: string
/** Mirrors today's content types: 'sql' means database, 'log_sql' means logs. */
contentType: 'sql' | 'log_sql'
sql: string
display: QueryDisplay
}
export type ChatMessage =
| { id: string; role: 'user'; text: string }
| { id: string; role: 'assistant'; text: string }
| {
id: string
role: 'assistant'
/** The agent query block — same QueryCell shape, rendered read-only. */
cell: QueryCellModel
approval: 'pending' | 'approved' | 'denied'
}
export type ChatSession = {
id: string
name: string
messages: ChatMessage[]
}
@@ -0,0 +1,32 @@
/**
* PROTOTYPE — provider so the product menu (sidebar) and the page content share
* one state instance.
*
* This mirrors how the real thing is wired: the Explorer tab store and notebook
* save coordinator are mounted at the layout level (like
* `SqlEditorSaveCoordinatorProvider` in `ProjectContext`), not inside the page,
* because the sidebar lives in `ProjectLayout`'s `productMenu` slot and the
* views live in `children`.
*/
import { createContext, useContext, type PropsWithChildren } from 'react'
import { useExplorerPrototypeState, type ExplorerPrototypeState } from './useExplorerPrototypeState'
const ExplorerPrototypeContext = createContext<ExplorerPrototypeState | undefined>(undefined)
export const ExplorerPrototypeProvider = ({ children }: PropsWithChildren) => {
const state = useExplorerPrototypeState()
return (
<ExplorerPrototypeContext.Provider value={state}>{children}</ExplorerPrototypeContext.Provider>
)
}
export const useExplorerPrototype = () => {
const context = useContext(ExplorerPrototypeContext)
if (context === undefined) {
throw new Error('useExplorerPrototype must be used within an ExplorerPrototypeProvider')
}
return context
}
@@ -0,0 +1,31 @@
/**
* PROTOTYPE — the one place that knows what each resource type looks like.
*
* The tab bar, the sidebar and each view's toolbar all read from here, so a
* fourth resource type is a single entry rather than three parallel edits.
*/
import { MessageSquare, NotebookText, SquareCode } from 'lucide-react'
import type { ComponentType } from 'react'
import type { TabResource } from './ExplorerPrototype.types'
export type ResourceType = TabResource['type']
export type ResourceIcon = ComponentType<{ size?: number; className?: string }>
export const RESOURCE_ICON: Record<ResourceType, ResourceIcon> = {
snippet: SquareCode,
notebook: NotebookText,
chat: MessageSquare,
}
export const RESOURCE_SECTIONS: Array<{
type: ResourceType
label: string
searchPlaceholder: string
}> = [
{ type: 'snippet', label: 'Snippets', searchPlaceholder: 'Search snippets' },
{ type: 'notebook', label: 'Notebooks', searchPlaceholder: 'Search notebooks' },
{ type: 'chat', label: 'Chats', searchPlaceholder: 'Search chats' },
]
@@ -0,0 +1,304 @@
/**
* PROTOTYPE — the Explorer sidebar, as a drill-down menu.
*
* Root level is three resource types plus a mixed "Recent" group, ordered by
* last modification. Choosing a type drills into that type's full list, with a
* search field and a back button. The sidebar never renders a resource — it
* only opens tabs.
*
* The drill-down is deliberately generic: `RESOURCE_SECTIONS` drives both
* levels, so a fourth resource type is one array entry, not new UI.
*
* Levels cross-fade with a small directional nudge (`LEVEL_OFFSET`), not a
* full-width slide — translating the whole panel reads as a zoom. Because
* `AnimatePresence` is in `mode="wait"`, the outgoing level finishes before the
* incoming one mounts, so only one level is ever in the DOM (and therefore in
* the accessibility tree and tab order).
*/
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
import { InnerSideBarFilters, InnerSideBarFilterSearchInput } from 'ui-patterns/InnerSideMenu'
import type {
ChatSession,
NotebookContent,
RecentItem,
SnippetDoc,
TabResource,
} from './ExplorerPrototype.types'
import { useExplorerPrototype } from './ExplorerPrototypeContext'
import { RESOURCE_ICON, RESOURCE_SECTIONS, type ResourceType } from './ExplorerResources'
/** One list entry, uniform across resource types. */
type ResourceEntry = { id: string; title: string }
export const notebookTitle = (notebook: NotebookContent) => {
const heading = notebook.cells.find((cell) => cell.type === 'markdown')
if (heading && heading.type === 'markdown') {
const firstLine = heading.markdown
.split('\n')[0]
.replace(/^#+\s*/, '')
.trim()
if (firstLine.length > 0) return firstLine
}
return 'Untitled notebook'
}
const formatModifiedAt = (modifiedAt: number) => {
const minutes = Math.round((Date.now() - modifiedAt) / 60_000)
if (minutes < 1) return 'Just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.round(hours / 24)}d ago`
}
/**
* Small enough to read as a nudge rather than a slide. Root sits to the left of
* the drilled level, so each direction keeps its sense: going deeper pushes the
* root out to the left and brings the list in from the right.
*/
const LEVEL_OFFSET = 8
const LEVEL_TRANSITION = { duration: 0.09, ease: 'easeOut' } as const
/** Matches `InnerSideMenuItem`'s recipe — that component is a Link, and these are buttons. */
const rowClassName = (isActive: boolean) =>
cn(
'group relative flex h-7 w-full items-center gap-2 rounded-md pl-3 pr-2 text-sm',
isActive
? 'bg-selection text-foreground'
: 'text-foreground-light hover:bg-surface-200 hover:text-foreground'
)
interface ExplorerSidebarProps {
notebooks: Record<string, NotebookContent>
snippets: Record<string, SnippetDoc>
chats: Record<string, ChatSession>
recentItems: RecentItem[]
activeResource?: TabResource
onOpen: (resource: TabResource, title: string) => void
}
export const ExplorerSidebar = ({
notebooks,
snippets,
chats,
recentItems,
activeResource,
onOpen,
}: ExplorerSidebarProps) => {
const [drilledType, setDrilledType] = useState<ResourceType | undefined>(undefined)
// Kept separately from `drilledType` so the level-1 panel still has a type to
// render from while it animates out, after `drilledType` has been cleared.
const [panelType, setPanelType] = useState<ResourceType>('snippet')
const [search, setSearch] = useState('')
const searchRef = useRef<HTMLInputElement>(null)
const isDrilled = drilledType !== undefined
// Focus follows the drill-down. An effect rather than the click handler
// because the input only exists after the level swap commits to the DOM.
useEffect(() => {
if (isDrilled) searchRef.current?.focus()
}, [isDrilled])
const isActive = (type: ResourceType, id: string) =>
activeResource?.type === type && activeResource.id === id
const titleFor = (resource: TabResource): string | undefined => {
if (resource.type === 'notebook') {
const notebook = notebooks[resource.id]
return notebook ? notebookTitle(notebook) : undefined
}
if (resource.type === 'snippet') return snippets[resource.id]?.name
return chats[resource.id]?.name
}
const entriesFor = (type: ResourceType): ResourceEntry[] => {
if (type === 'notebook') {
return Object.entries(notebooks).map(([id, notebook]) => ({
id,
title: notebookTitle(notebook),
}))
}
if (type === 'snippet') {
return Object.entries(snippets).map(([id, snippet]) => ({ id, title: snippet.name }))
}
return Object.entries(chats).map(([id, chat]) => ({ id, title: chat.name }))
}
const drillInto = (type: ResourceType) => {
setSearch('')
setPanelType(type)
setDrilledType(type)
}
const goBack = () => {
setSearch('')
setDrilledType(undefined)
}
const section = RESOURCE_SECTIONS.find((entry) => entry.type === panelType)
const visibleEntries = entriesFor(panelType).filter((entry) =>
entry.title.toLowerCase().includes(search.trim().toLowerCase())
)
// Resources can be deleted out from under a recent entry.
const resolvedRecents = recentItems.flatMap((item) => {
const title = titleFor(item.resource)
return title === undefined ? [] : [{ ...item, title }]
})
return (
<div className="relative h-full overflow-hidden">
<AnimatePresence mode="wait">
{!isDrilled ? (
/* Level 0 — resource types + recents */
<motion.div
key="root"
role="group"
aria-label="All resources"
initial={{ opacity: 0, x: -LEVEL_OFFSET }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -LEVEL_OFFSET }}
transition={LEVEL_TRANSITION}
className="absolute inset-0 flex flex-col gap-4 overflow-y-auto p-3"
>
<nav className="flex flex-col gap-px">
{RESOURCE_SECTIONS.map(({ type, label }) => {
const Icon = RESOURCE_ICON[type]
return (
<button
key={type}
type="button"
tabIndex={0}
className={rowClassName(false)}
onClick={() => drillInto(type)}
>
<Icon size={14} className="shrink-0" />
<span className="flex-1 text-left">{label}</span>
<span className="text-xs text-foreground-lighter">
{entriesFor(type).length}
</span>
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
</button>
)
})}
</nav>
<section className="flex flex-col gap-px">
<h3 className="heading-meta mb-1 px-3 text-muted-foreground">Recent</h3>
{resolvedRecents.map((item) => {
const Icon = RESOURCE_ICON[item.resource.type]
return (
<button
key={`${item.resource.type}-${item.resource.id}`}
type="button"
tabIndex={0}
className={rowClassName(isActive(item.resource.type, item.resource.id))}
onClick={() => onOpen(item.resource, item.title)}
>
<Icon size={14} className="shrink-0 text-foreground-muted" />
<span className="flex-1 truncate text-left">{item.title}</span>
<span className="shrink-0 text-xs text-foreground-lighter">
{formatModifiedAt(item.modifiedAt)}
</span>
</button>
)
})}
{resolvedRecents.length === 0 && (
<p className="px-3 text-xs text-foreground-lighter">Nothing edited yet</p>
)}
</section>
</motion.div>
) : (
/* Level 1 — one resource type, searchable */
<motion.div
key={panelType}
role="group"
aria-label={section?.label}
initial={{ opacity: 0, x: LEVEL_OFFSET }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: LEVEL_OFFSET }}
transition={LEVEL_TRANSITION}
className="absolute inset-0 flex flex-col"
>
<div className="flex items-center gap-1 p-3 pb-2">
<button
type="button"
tabIndex={0}
aria-label="Back to all resources"
onClick={goBack}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-foreground-light hover:bg-surface-200 hover:text-foreground"
>
<ChevronLeft size={16} />
</button>
<span id="explorer-sidebar-search-label" className="sr-only">
{section?.searchPlaceholder}
</span>
<InnerSideBarFilters className="w-full gap-0 p-0">
<InnerSideBarFilterSearchInput
ref={searchRef}
name="explorer-sidebar-search"
value={search}
placeholder={section?.searchPlaceholder}
aria-labelledby="explorer-sidebar-search-label"
onChange={(event) => setSearch(event.target.value)}
/>
</InnerSideBarFilters>
</div>
<div className="flex flex-1 flex-col gap-px overflow-y-auto px-3 pb-3">
{visibleEntries.map((entry) => {
const Icon = RESOURCE_ICON[panelType]
return (
<button
key={entry.id}
type="button"
tabIndex={0}
className={rowClassName(isActive(panelType, entry.id))}
onClick={() => onOpen({ type: panelType, id: entry.id }, entry.title)}
>
<Icon size={14} className="shrink-0 text-foreground-muted" />
<span className="flex-1 truncate text-left">{entry.title}</span>
</button>
)
})}
{visibleEntries.length === 0 && (
<p className="px-3 py-2 text-xs text-foreground-lighter">
{search.trim().length > 0
? `No ${section?.label.toLowerCase()} match "${search.trim()}"`
: `No ${section?.label.toLowerCase()} yet`}
</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
/**
* Connected wrapper for `ProjectLayout`'s `productMenu` slot — the same slot the
* SQL editor fills with `SQLEditorMenu`.
*/
export const ExplorerSidebarMenu = () => {
const state = useExplorerPrototype()
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
return (
<ExplorerSidebar
notebooks={state.notebooks}
snippets={state.snippets}
chats={state.chats}
recentItems={state.recentItems}
activeResource={activeTab?.resource}
onOpen={state.openTab}
/>
)
}
@@ -0,0 +1,81 @@
/**
* PROTOTYPE — the Explorer tab bar.
*
* The bar is agnostic of what a tab contains: it reads `tab.resource.type` only
* to pick an icon. Everything else (title, dirty state, close) is uniform, which
* is what makes adding a fourth resource type cheap later.
*/
import { X } from 'lucide-react'
import { cn } from 'ui'
import type { Tab } from './ExplorerPrototype.types'
import { RESOURCE_ICON } from './ExplorerResources'
interface ExplorerTabBarProps {
tabs: Tab[]
activeTabId: string
dirtyResources: Record<string, boolean>
onSelect: (tabId: string) => void
onClose: (tabId: string) => void
}
export const ExplorerTabBar = ({
tabs,
activeTabId,
dirtyResources,
onSelect,
onClose,
}: ExplorerTabBarProps) => (
<div className="flex h-full items-stretch overflow-x-auto">
{tabs.map((tab) => {
const Icon = RESOURCE_ICON[tab.resource.type]
const isActive = tab.id === activeTabId
const isDirty = dirtyResources[tab.resource.id] ?? false
return (
<div
key={tab.id}
className={cn(
'group/tab relative flex items-center border-r',
// The active tab is the page surface pushed up into the muted row,
// so it reads as continuous with the content below it.
isActive ? 'bg-background' : 'bg-transparent hover:bg-surface-200'
)}
>
<button
type="button"
tabIndex={0}
onClick={() => onSelect(tab.id)}
className="flex h-full items-center gap-2 pl-3 pr-8 text-xs"
>
<Icon size={14} className="shrink-0 text-foreground-muted" />
<span className="max-w-40 truncate">{tab.title}</span>
</button>
{isDirty && (
<span
aria-label="Unsaved changes"
className="absolute right-3 size-1.5 rounded-full bg-foreground-light group-hover/tab:opacity-0"
/>
)}
<button
type="button"
tabIndex={0}
aria-label={`Close ${tab.title}`}
onClick={() => onClose(tab.id)}
className={cn(
'absolute right-1.5 flex size-5 items-center justify-center rounded-xs',
'opacity-0 hover:bg-surface-300 group-hover/tab:opacity-100'
)}
>
<X size={12} className="text-foreground-light" />
</button>
{isActive && <div className="absolute inset-x-0 top-0 h-px bg-foreground" />}
</div>
)
})}
</div>
)
@@ -0,0 +1,211 @@
/**
* PROTOTYPE — the shared QueryCell component (PR E4).
*
* This is the "generic SQL editor" from the design diagram: toolbar, SQL editor,
* results (table or chart). It is rendered by all three surfaces:
*
* - Snippet view — editable, via the snippet adapter
* - Notebook cell — editable
* - Agent chat block — `readOnly`, still runnable behind approval
*
* It is fully controlled. It owns no fetching and no persistence: the caller
* passes `result` and handles `onRun`, so each surface decides where results
* live. Surface-specific chrome comes in through the three slots.
*/
import { AlertTriangle, Play } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { Badge, Button, cn } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { isWriteQuery } from './ExplorerPrototype.mocks'
import type {
CellResultState,
CellSource,
QueryCellModel,
QueryDisplay,
} from './ExplorerPrototype.types'
import { QueryCellDisplay } from './QueryCellDisplay'
import { QueryCellDisplayConfig } from './QueryCellDisplayConfig'
import { QueryCellSourceMenu } from './QueryCellSourceMenu'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
const editorHeight = (sql: string) => {
const lines = sql.split('\n').length
return Math.min(Math.max(lines * 19 + 24, 80), 260)
}
export interface QueryCellProps {
value: QueryCellModel
result: CellResultState
/** Effective limit, already resolved from cell override + notebook default. */
rowLimit: number
readOnly?: boolean
/**
* Below the results — assistant approval, notebook run-mode hints.
*
* There is deliberately no header or actions slot: controls that belong to
* the *surface* rather than the query (reorder, delete, insert) live outside
* this component, so the cell renders identically everywhere it appears.
*/
footerSlot?: ReactNode
onChange?: (next: QueryCellModel) => void
onRun?: () => void
}
export const QueryCell = ({
value,
result,
rowLimit,
readOnly = false,
footerSlot,
onChange,
onRun,
}: QueryCellProps) => {
const [pendingWriteConfirm, setPendingWriteConfirm] = useState(false)
const isWrite = isWriteQuery(value.query.sql)
const isRunning = result.status === 'running'
const columns = result.status === 'success' ? Object.keys(result.rows[0] ?? {}) : []
const update = (next: Partial<QueryCellModel>) => onChange?.({ ...value, ...next })
const updateSource = (source: CellSource) => update({ query: { ...value.query, source } })
const updateSql = (sql: string) => update({ query: { ...value.query, sql } })
const updateDisplay = (display: QueryDisplay) => update({ display })
const updateRowLimit = (limit: number) =>
update({ execution: { ...value.execution, row_limit: limit } })
const handleRun = () => {
if (isRunning) return
// Write queries never run without an explicit confirmation, on any surface.
if (isWrite && !pendingWriteConfirm) {
setPendingWriteConfirm(true)
return
}
setPendingWriteConfirm(false)
onRun?.()
}
return (
<div className="flex flex-col overflow-hidden rounded-md border bg-surface-100 shadow-xs">
{/* Toolbar */}
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-2">
{readOnly ? (
<span className="truncate px-1 text-xs font-medium">{value.name}</span>
) : (
// Borderless and ring-less: the cell already has a border, and the
// input's own default border/focus ring reads as a double outline.
<input
value={value.name}
aria-label="Cell name"
onChange={(event) => update({ name: event.target.value })}
className={cn(
'min-w-0 flex-1 truncate rounded-sm border-0 bg-transparent px-1 text-xs font-medium shadow-none',
'outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0',
'focus-visible:outline-none focus-visible:ring-0 focus:bg-surface-200'
)}
/>
)}
{isWrite && (
<Badge variant="warning" className="shrink-0">
Write
</Badge>
)}
<div className="ml-auto flex shrink-0 items-center gap-1">
<QueryCellSourceMenu
source={value.query.source}
rowLimit={rowLimit}
disabled={readOnly}
onSourceChange={updateSource}
onRowLimitChange={updateRowLimit}
/>
{columns.length > 0 && (
<QueryCellDisplayConfig
display={value.display}
columns={columns}
onChange={updateDisplay}
/>
)}
<Button
variant="text"
size="tiny"
className="w-7 px-0"
icon={<Play size={14} strokeWidth={1.5} />}
loading={isRunning}
disabled={isRunning || !onRun}
aria-label="Run cell"
onClick={handleRun}
/>
</div>
</div>
{/* Write confirmation */}
{pendingWriteConfirm && (
<div className="flex items-center gap-3 border-b bg-warning-200 px-3 py-2">
<AlertTriangle size={16} className="shrink-0 text-warning-600" />
<p className="flex-1 text-xs text-foreground-light">
This query modifies data and will not run automatically.
</p>
<Button variant="default" size="tiny" onClick={() => setPendingWriteConfirm(false)}>
Cancel
</Button>
<Button variant="warning" size="tiny" onClick={handleRun}>
Run anyway
</Button>
</div>
)}
{/* SQL editor */}
<div className="shrink-0 border-b" style={{ height: editorHeight(value.query.sql) }}>
{readOnly ? (
<CodeBlock
hideLineNumbers
wrapLines={false}
value={value.query.sql}
language="sql"
className={cn(
'block h-full w-full max-w-none overflow-auto rounded-none! border-0 bg-transparent! px-3.5! py-3! text-foreground',
'[&>code]:m-0 [&>code>span]:text-foreground'
)}
/>
) : (
<CodeEditor
id={value.id}
language="pgsql"
value={value.query.sql}
autofocus={false}
hideLineNumbers
onInputChange={(next) => updateSql(next ?? '')}
actions={{ runQuery: { enabled: true, callback: handleRun } }}
options={{ scrollBeyondLastLine: false, padding: { top: 10, bottom: 10 } }}
/>
)}
</div>
{/* Results */}
{result.status === 'running' && (
<div className="w-full p-3">
<ShimmeringLoader />
</div>
)}
{result.status === 'error' && (
<div className="w-full px-3.5 py-2">
<span className="font-mono text-xs text-destructive-600">ERROR: {result.message}</span>
</div>
)}
{result.status === 'success' && (
<>
<QueryCellDisplay display={value.display} rows={result.rows} />
<p className="border-t px-3 py-1 font-mono text-xs text-foreground-lighter">
{result.rows.length} rows · limit {result.rowLimitApplied}
</p>
</>
)}
{footerSlot}
</div>
)
}
@@ -0,0 +1,166 @@
/**
* PROTOTYPE — the `QueryDisplay` renderer (PR E5).
*
* Two capabilities here that today's QueryBlock does not have, and which the
* spec's `ChartDisplay` requires: **multi-series** (`series[]` with per-series
* labels) and **line charts** (declared but unimplemented in QueryBlock today).
*/
import dayjs from 'dayjs'
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { ChartContainer, ChartTooltipContent, cn } from 'ui'
import type { ChartDisplay, QueryDisplay, ResultRow } from './ExplorerPrototype.types'
import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
import { CHART_COLORS } from '@/components/ui/Charts/Charts.constants'
const SERIES_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)']
/** Running total per series, computed independently for each field. */
export const applyCumulative = (rows: ResultRow[], fields: string[]): ResultRow[] => {
const totals: Record<string, number> = {}
return rows.map((row) => {
const next: ResultRow = { ...row }
fields.forEach((field) => {
totals[field] = (totals[field] ?? 0) + Number(row[field] ?? 0)
next[field] = totals[field]
})
return next
})
}
const looksLikeDate = (value: unknown) =>
typeof value === 'string' && value.length >= 8 && dayjs(value).isValid()
interface ChartRendererProps {
chart: ChartDisplay
rows: ResultRow[]
}
const ChartRenderer = ({ chart, rows }: ChartRendererProps) => {
const seriesFields = chart.series.map((entry) => entry.field)
const missingField = !chart.x_axis.field || seriesFields.length === 0
if (missingField) {
return (
<div className="flex h-[240px] w-full items-center justify-center">
<p className="text-xs text-foreground-light">Choose an x-axis and at least one series</p>
</div>
)
}
const data = chart.cumulative ? applyCumulative(rows, seriesFields) : rows
const isDateAxis = looksLikeDate(data[0]?.[chart.x_axis.field])
const formatX = (value: unknown) =>
isDateAxis ? dayjs(value as string).format('MMM D') : String(value)
const axes = (
<>
<CartesianGrid vertical={false} stroke={CHART_COLORS.AXIS} />
<XAxis
dataKey={chart.x_axis.field}
tickLine={{ stroke: CHART_COLORS.AXIS }}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickMargin={4}
minTickGap={24}
tickFormatter={formatX}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
width={48}
scale={chart.log_scale ? 'log' : 'auto'}
domain={chart.log_scale ? [1, 'auto'] : undefined}
allowDataOverflow={chart.log_scale}
/>
<Tooltip
content={
<ChartTooltipContent
className="min-w-[200px]"
labelFormatter={(value) => formatX(value)}
/>
}
/>
{chart.series.length > 1 && <Legend iconType="circle" iconSize={8} />}
</>
)
return (
<ChartContainer className="aspect-auto px-3 py-2" style={{ height: 240, minHeight: 240 }}>
{chart.type === 'line' ? (
<LineChart data={data} margin={{ left: 0, right: 8, top: 10 }}>
{axes}
{chart.series.map((entry, index) => (
<Line
key={entry.field}
type="monotone"
dataKey={entry.field}
name={entry.label ?? entry.field}
stroke={SERIES_COLORS[index % SERIES_COLORS.length]}
strokeWidth={2}
dot={false}
label={chart.show_labels ? { fontSize: 10, position: 'top' } : undefined}
/>
))}
</LineChart>
) : (
<BarChart data={data} margin={{ left: 0, right: 8, top: 10 }}>
{axes}
{chart.series.map((entry, index) => (
<Bar
key={entry.field}
radius={1}
dataKey={entry.field}
name={entry.label ?? entry.field}
fill={SERIES_COLORS[index % SERIES_COLORS.length]}
label={chart.show_labels ? { fontSize: 10, position: 'top' } : undefined}
/>
))}
</BarChart>
)}
</ChartContainer>
)
}
interface QueryCellDisplayProps {
display: QueryDisplay
rows: ResultRow[]
className?: string
}
export const QueryCellDisplay = ({ display, rows, className }: QueryCellDisplayProps) => {
if (rows.length === 0) {
return (
<div className="flex h-24 w-full items-center justify-center">
<p className="text-xs text-foreground-light">No rows returned</p>
</div>
)
}
if (display.type === 'chart') {
return (
<div className={cn('w-full', className)}>
<ChartRenderer chart={display.chart} rows={rows} />
</div>
)
}
return (
<div
className={cn('flex max-h-72 w-full flex-col overflow-auto overscroll-contain', className)}
>
<Results rows={rows} />
</div>
)
}
@@ -0,0 +1,217 @@
/**
* PROTOTYPE — display configuration popover.
*
* Same slot as today's `BlockViewConfiguration`, but it emits the spec's richer
* shape: chart type, one x-axis field, and a **list** of series with optional
* per-series labels.
*/
import { BarChart2, Plus, Settings2, Table as TableIcon, X } from 'lucide-react'
import {
Button,
Checkbox,
Label,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
ToggleGroup,
ToggleGroupItem,
} from 'ui'
import type { ChartDisplay, QueryDisplay } from './ExplorerPrototype.types'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
const DEFAULT_CHART: ChartDisplay = {
type: 'bar',
x_axis: { field: '' },
series: [],
}
interface QueryCellDisplayConfigProps {
display: QueryDisplay
columns: string[]
onChange: (display: QueryDisplay) => void
}
export const QueryCellDisplayConfig = ({
display,
columns,
onChange,
}: QueryCellDisplayConfigProps) => {
const chart = display.type === 'chart' ? display.chart : DEFAULT_CHART
const usedFields = chart.series.map((entry) => entry.field)
const availableFields = columns.filter(
(column) => column !== chart.x_axis.field && !usedFields.includes(column)
)
const updateChart = (next: Partial<ChartDisplay>) =>
onChange({ type: 'chart', chart: { ...chart, ...next } })
return (
<Popover modal={false}>
<PopoverTrigger asChild>
<ButtonTooltip
variant="text"
className="px-1"
icon={<Settings2 size={14} strokeWidth={1.5} />}
tooltip={{ content: { side: 'bottom', text: 'Display' } }}
/>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-[280px] p-3">
<div className="grid gap-3">
<ToggleGroup
type="single"
variant="outline"
value={display.type}
className="w-full"
onValueChange={(value: 'table' | 'chart') => {
if (!value) return
if (value === 'table') return onChange({ type: 'table' })
onChange({
type: 'chart',
chart: {
...chart,
x_axis: { field: chart.x_axis.field || columns[0] || '' },
series:
chart.series.length > 0
? chart.series
: columns[1]
? [{ field: columns[1] }]
: [],
},
})
}}
>
<ToggleGroupItem className="w-full" value="table" aria-label="Show as table">
<TableIcon className="h-4 w-4" />
<p className="ml-2 text-xs">Table</p>
</ToggleGroupItem>
<ToggleGroupItem className="w-full" value="chart" aria-label="Show as chart">
<BarChart2 className="h-4 w-4" />
<p className="ml-2 text-xs">Chart</p>
</ToggleGroupItem>
</ToggleGroup>
{display.type === 'chart' && (
<>
<ToggleGroup
type="single"
variant="outline"
value={chart.type}
className="w-full"
onValueChange={(value: 'bar' | 'line') => value && updateChart({ type: value })}
>
<ToggleGroupItem className="w-full" value="bar" aria-label="Bar chart">
<p className="text-xs">Bar</p>
</ToggleGroupItem>
<ToggleGroupItem className="w-full" value="line" aria-label="Line chart">
<p className="text-xs">Line</p>
</ToggleGroupItem>
</ToggleGroup>
<Select
value={chart.x_axis.field}
onValueChange={(field) => updateChart({ x_axis: { field } })}
>
<SelectTrigger className="text-left">
X axis {chart.x_axis.field && `— ${chart.x_axis.field}`}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{columns.map((column) => (
<SelectItem value={column} key={column}>
{column}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="grid gap-1">
<p className="text-xs text-foreground-light">Series</p>
{chart.series.map((entry, index) => (
<div key={entry.field} className="flex items-center gap-1">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: `var(--chart-${(index % 4) + 1})` }}
/>
<span className="flex-1 truncate text-xs">{entry.label ?? entry.field}</span>
<Button
variant="text"
size="tiny"
icon={<X size={12} />}
aria-label={`Remove ${entry.field}`}
onClick={() =>
updateChart({
series: chart.series.filter((series) => series.field !== entry.field),
})
}
/>
</div>
))}
{chart.series.length === 0 && (
<p className="text-xs text-foreground-lighter">No series selected</p>
)}
{availableFields.length > 0 && (
<Select
value=""
onValueChange={(field) => updateChart({ series: [...chart.series, { field }] })}
>
<SelectTrigger className="text-left">
<span className="flex items-center gap-1.5 text-foreground-light">
<Plus size={12} /> Add series
</span>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableFields.map((column) => (
<SelectItem value={column} key={column}>
{column}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
<div className="grid gap-2 *:flex *:items-center *:gap-2 *:p-1.5 *:pl-0 *:text-foreground-light">
<Label htmlFor="cumulative">
<Checkbox
id="cumulative"
checked={chart.cumulative ?? false}
onClick={() => updateChart({ cumulative: !chart.cumulative })}
/>
Cumulative
</Label>
<Label htmlFor="showLabels">
<Checkbox
id="showLabels"
checked={chart.show_labels ?? false}
onClick={() => updateChart({ show_labels: !chart.show_labels })}
/>
Show labels
</Label>
<Label htmlFor="logScale">
<Checkbox
id="logScale"
checked={chart.log_scale ?? false}
onClick={() => updateChart({ log_scale: !chart.log_scale })}
/>
Log scale
</Label>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,164 @@
/**
* PROTOTYPE — the per-cell source picker.
*
* In the real build this is PR E1's controlled `QuerySourceMenu`: same idea,
* fully driven by props, no editor context. Source is per-cell here, which is
* the departure from today's snippets (where source is fixed by content type).
*/
import { Check, ChevronDown, Database, ScrollText } from 'lucide-react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from 'ui'
import { MOCK_REPLICAS } from './ExplorerPrototype.mocks'
import type { CellSource, LogTimeRange } from './ExplorerPrototype.types'
const RELATIVE_RANGES: Array<{ label: string; range: LogTimeRange }> = [
{ label: 'Last 1 hour', range: { type: 'relative', amount: 1, unit: 'hour' } },
{ label: 'Last 24 hours', range: { type: 'relative', amount: 24, unit: 'hour' } },
{ label: 'Last 7 days', range: { type: 'relative', amount: 7, unit: 'day' } },
]
const ROW_LIMITS = [50, 100, 500, 1000]
export const describeSource = (source: CellSource) => {
if (source.id === 'database') {
const replica = MOCK_REPLICAS.find((entry) => entry.identifier === source.parameters.identifier)
return replica?.label ?? 'Primary database'
}
const range = source.parameters.time_range
if (range.type === 'relative') {
return `Logs · last ${range.amount} ${range.unit}${range.amount === 1 ? '' : 's'}`
}
return `Logs · ${range.from} to ${range.to}`
}
const describeRange = (range: LogTimeRange) =>
range.type === 'relative'
? `Last ${range.amount} ${range.unit}${range.amount === 1 ? '' : 's'}`
: 'Custom range'
interface QueryCellSourceMenuProps {
source: CellSource
rowLimit: number
disabled?: boolean
onSourceChange: (source: CellSource) => void
onRowLimitChange: (rowLimit: number) => void
}
export const QueryCellSourceMenu = ({
source,
rowLimit,
disabled = false,
onSourceChange,
onRowLimitChange,
}: QueryCellSourceMenuProps) => {
const isLogs = source.id === 'logs'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="text"
size="tiny"
disabled={disabled}
icon={isLogs ? <ScrollText size={14} /> : <Database size={14} />}
iconRight={<ChevronDown size={12} />}
className="text-foreground-light"
>
{describeSource(source)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Source</DropdownMenuLabel>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<Database size={14} /> Database
{!isLogs && <Check size={14} className="ml-auto" />}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuRadioGroup
value={source.id === 'database' ? (source.parameters.identifier ?? 'primary') : ''}
onValueChange={(identifier) =>
onSourceChange({ id: 'database', parameters: { identifier } })
}
>
{MOCK_REPLICAS.map((replica) => (
<DropdownMenuRadioItem key={replica.identifier} value={replica.identifier}>
{replica.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<ScrollText size={14} /> Logs
{isLogs && <Check size={14} className="ml-auto" />}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuLabel>Time range</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={isLogs ? describeRange(source.parameters.time_range) : ''}
onValueChange={(label) => {
const match = RELATIVE_RANGES.find((entry) => entry.label === label)
if (match) onSourceChange({ id: 'logs', parameters: { time_range: match.range } })
}}
>
{RELATIVE_RANGES.map((entry) => (
<DropdownMenuRadioItem key={entry.label} value={entry.label}>
{entry.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
onSourceChange({
id: 'logs',
parameters: {
time_range: {
type: 'absolute',
from: new Date(Date.now() - 86_400_000).toISOString(),
to: new Date().toISOString(),
},
},
})
}
>
Custom range…
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuLabel>Row limit</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={String(rowLimit)}
onValueChange={(value) => onRowLimitChange(Number(value))}
>
{ROW_LIMITS.map((limit) => (
<DropdownMenuRadioItem key={limit} value={String(limit)}>
{limit} rows
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,26 @@
/**
* PROTOTYPE — the toolbar every tab type shows above its content.
*
* Deliberately uniform: icon + name on the left, primary actions on the right,
* and the same height as the tab strip so the two rows read as one header.
* No badges — resource type is already carried by the icon and the tab.
*/
import type { ReactNode } from 'react'
import type { ResourceIcon } from './ExplorerResources'
interface TabToolbarProps {
icon: ResourceIcon
title: string
/** Omitted entirely when a view has no primary actions. */
actions?: ReactNode
}
export const TabToolbar = ({ icon: Icon, title, actions }: TabToolbarProps) => (
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-3 md:min-h-(--header-height)">
<Icon size={14} className="shrink-0 text-foreground-muted" />
<h2 className="truncate text-sm">{title}</h2>
{actions !== undefined && <div className="ml-auto flex items-center gap-1">{actions}</div>}
</div>
)
@@ -0,0 +1,265 @@
/**
* PROTOTYPE — one hook standing in for what will become several real stores
* (Explorer tab store, notebook state + save coordinator, per-cell session
* results). Kept in one place so the data flow is readable end to end.
*/
import { useState } from 'react'
import {
INITIAL_CHATS,
INITIAL_NOTEBOOKS,
INITIAL_RECENT_ITEMS,
INITIAL_SNIPPETS,
INITIAL_TABS,
isWriteQuery,
runMockQuery,
} from './ExplorerPrototype.mocks'
import type {
CellResultState,
ChatSession,
NotebookCell,
NotebookContent,
QueryCellModel,
RecentItem,
RunMode,
SnippetDoc,
Tab,
TabResource,
} from './ExplorerPrototype.types'
let idCounter = 0
const nextId = (prefix: string) => `${prefix}-${++idCounter}`
const RECENT_ITEM_LIMIT = 8
/**
* The rule PR N9 extracts as a pure function so the Assistant enforces the same
* policy: a cell's own run_mode wins, otherwise the notebook default.
*/
export const resolveEffectiveRunMode = (
cell: QueryCellModel,
settings: NotebookContent['settings']
): RunMode => cell.execution?.run_mode ?? settings.run_mode
export const resolveEffectiveRowLimit = (
cell: QueryCellModel,
settings: NotebookContent['settings']
): number => cell.execution?.row_limit ?? settings.default_row_limit
export const useExplorerPrototypeState = () => {
const [tabs, setTabs] = useState<Tab[]>(INITIAL_TABS)
const [activeTabId, setActiveTabId] = useState<string>(INITIAL_TABS[0].id)
const [notebooks, setNotebooks] = useState<Record<string, NotebookContent>>(INITIAL_NOTEBOOKS)
const [snippets, setSnippets] = useState<Record<string, SnippetDoc>>(INITIAL_SNIPPETS)
const [chats, setChats] = useState<Record<string, ChatSession>>(INITIAL_CHATS)
const [results, setResults] = useState<Record<string, CellResultState>>({})
const [dirtyResources, setDirtyResources] = useState<Record<string, boolean>>({})
const [recentItems, setRecentItems] = useState<RecentItem[]>(INITIAL_RECENT_ITEMS)
/**
* Marks a resource dirty *and* bumps it to the top of "Recent". Recents track
* modification, not navigation — opening a tab deliberately does not touch
* this, otherwise the list would just mirror whatever you last clicked.
*/
const markModified = (resource: TabResource) => {
setDirtyResources((current) => ({ ...current, [resource.id]: true }))
setRecentItems((current) =>
[
{ resource, modifiedAt: Date.now() },
...current.filter(
(item) => !(item.resource.type === resource.type && item.resource.id === resource.id)
),
].slice(0, RECENT_ITEM_LIMIT)
)
}
// -- tabs -----------------------------------------------------------------
const openTab = (resource: TabResource, title: string) => {
const existing = tabs.find(
(tab) => tab.resource.type === resource.type && tab.resource.id === resource.id
)
if (existing) {
setActiveTabId(existing.id)
return
}
const tab: Tab = { id: nextId('tab'), title, resource }
setTabs((current) => [...current, tab])
setActiveTabId(tab.id)
}
const closeTab = (tabId: string) => {
setTabs((current) => {
const remaining = current.filter((tab) => tab.id !== tabId)
setActiveTabId((active) => {
if (active !== tabId) return active
const closedIndex = current.findIndex((tab) => tab.id === tabId)
const fallback = remaining[closedIndex] ?? remaining[closedIndex - 1] ?? remaining[0]
return fallback?.id ?? ''
})
return remaining
})
}
const reorderTabs = (fromId: string, toId: string) => {
setTabs((current) => {
const fromIndex = current.findIndex((tab) => tab.id === fromId)
const toIndex = current.findIndex((tab) => tab.id === toId)
if (fromIndex < 0 || toIndex < 0) return current
const next = [...current]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
})
}
// -- notebooks ------------------------------------------------------------
const updateNotebook = (
notebookId: string,
update: (notebook: NotebookContent) => NotebookContent
) => {
setNotebooks((current) => ({ ...current, [notebookId]: update(current[notebookId]) }))
markModified({ type: 'notebook', id: notebookId })
}
const updateCell = (notebookId: string, cellId: string, next: NotebookCell) =>
updateNotebook(notebookId, (notebook) => ({
...notebook,
cells: notebook.cells.map((cell) => (cell.id === cellId ? next : cell)),
}))
const addCell = (notebookId: string, type: 'query' | 'markdown', afterCellId?: string) => {
const cell: NotebookCell =
type === 'markdown'
? { id: nextId('cell'), type: 'markdown', markdown: '' }
: {
id: nextId('cell'),
type: 'query',
name: 'Untitled query',
query: {
type: 'inline',
source: { id: 'database', parameters: { identifier: 'primary' } },
sql: 'select ',
},
display: { type: 'table' },
}
updateNotebook(notebookId, (notebook) => {
const index = afterCellId
? notebook.cells.findIndex((existing) => existing.id === afterCellId) + 1
: notebook.cells.length
const cells = [...notebook.cells]
cells.splice(index, 0, cell)
return { ...notebook, cells }
})
}
const removeCell = (notebookId: string, cellId: string) =>
updateNotebook(notebookId, (notebook) => ({
...notebook,
cells: notebook.cells.filter((cell) => cell.id !== cellId),
}))
const moveCell = (notebookId: string, cellId: string, direction: -1 | 1) =>
updateNotebook(notebookId, (notebook) => {
const index = notebook.cells.findIndex((cell) => cell.id === cellId)
const target = index + direction
if (index < 0 || target < 0 || target >= notebook.cells.length) return notebook
const cells = [...notebook.cells]
const [moved] = cells.splice(index, 1)
cells.splice(target, 0, moved)
return { ...notebook, cells }
})
const updateNotebookSettings = (notebookId: string, settings: NotebookContent['settings']) =>
updateNotebook(notebookId, (notebook) => ({ ...notebook, settings }))
// -- snippets & chat ------------------------------------------------------
const updateSnippet = (snippetId: string, next: SnippetDoc) => {
setSnippets((current) => ({ ...current, [snippetId]: next }))
markModified({ type: 'snippet', id: snippetId })
}
const setChatApproval = (chatId: string, messageId: string, approval: 'approved' | 'denied') => {
setChats((current) => ({
...current,
[chatId]: {
...current[chatId],
messages: current[chatId].messages.map((message) =>
message.id === messageId && 'approval' in message ? { ...message, approval } : message
),
},
}))
// Resolving an approval changes the stored session, so the chat counts as modified.
markModified({ type: 'chat', id: chatId })
}
// -- execution ------------------------------------------------------------
const runCell = async (cell: QueryCellModel, rowLimit: number) => {
setResults((current) => ({ ...current, [cell.id]: { status: 'running' } }))
const outcome = await runMockQuery({
source: cell.query.source,
sql: cell.query.sql,
rowLimit,
})
setResults((current) => ({
...current,
[cell.id]:
outcome.status === 'error'
? { status: 'error', message: outcome.message }
: {
status: 'success',
rows: outcome.rows,
ranAt: new Date().toISOString(),
rowLimitApplied: rowLimit,
},
}))
}
/**
* Notebook "run all" and the on_open pass share this. Write-detected queries
* never auto-run — they stop the sequence and wait for explicit confirmation.
*/
const runNotebook = async (notebookId: string) => {
const notebook = notebooks[notebookId]
if (!notebook) return
for (const cell of notebook.cells) {
if (cell.type !== 'query') continue
if (isWriteQuery(cell.query.sql)) break
await runCell(cell, resolveEffectiveRowLimit(cell, notebook.settings))
}
}
return {
tabs,
activeTabId,
setActiveTabId,
openTab,
closeTab,
reorderTabs,
notebooks,
updateCell,
addCell,
removeCell,
moveCell,
updateNotebookSettings,
snippets,
updateSnippet,
chats,
setChatApproval,
results,
recentItems,
runCell,
runNotebook,
dirtyResources,
}
}
export type ExplorerPrototypeState = ReturnType<typeof useExplorerPrototypeState>
@@ -0,0 +1,132 @@
/**
* PROTOTYPE — Agent chat view.
*
* The agent query block is the *same* QueryCell component the notebook and
* snippet views render, passed `readOnly`. What differs is chrome, not machinery:
* an approval footer in the footer slot, and a data-sharing gate on the results.
*
* Messages stay at a readable measure; agent query blocks run the full width,
* matching how a query cell behaves in a notebook.
*/
import { Check, X } from 'lucide-react'
import { useState } from 'react'
import { Button, cn, Switch } from 'ui'
import type { CellResultState, ChatSession, QueryCellModel } from '../ExplorerPrototype.types'
import { RESOURCE_ICON } from '../ExplorerResources'
import { QueryCell } from '../QueryCell'
import { TabToolbar } from '../TabToolbar'
import { Markdown } from '@/components/interfaces/Markdown'
interface ChatViewProps {
chat: ChatSession
results: Record<string, CellResultState>
onApprove: (messageId: string, cell: QueryCellModel) => void
onDeny: (messageId: string) => void
}
const CHAT_ROW_LIMIT = 100
/** Message measure — agent query blocks deliberately break out of this. */
const PROSE_WIDTH = 'mx-auto w-full max-w-3xl'
export const ChatView = ({ chat, results, onApprove, onDeny }: ChatViewProps) => {
// Stands in for `useOrgAiOptInLevel()` — results only reach the model at
// the `schema_and_log_and_data` level.
const [canShareResults, setCanShareResults] = useState(false)
return (
<div className="flex h-full flex-col">
<TabToolbar
icon={RESOURCE_ICON.chat}
title={chat.name}
actions={
<span className="flex items-center gap-2 text-xs text-foreground-light">
Share results with the Assistant
<Switch
aria-label="Share results with the Assistant"
checked={canShareResults}
onCheckedChange={setCanShareResults}
/>
</span>
}
/>
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-4 p-4">
{chat.messages.map((message) => {
if (!('cell' in message)) {
return (
<div key={message.id} className={cn('flex', PROSE_WIDTH)}>
<div
className={cn(
'rounded-md px-3 py-2 text-sm',
message.role === 'user' ? 'ml-auto bg-surface-200' : 'bg-transparent'
)}
>
<Markdown content={message.text} className="max-w-none" />
</div>
</div>
)
}
const result = results[message.cell.id] ?? { status: 'idle' as const }
const isPending = message.approval === 'pending'
const isDenied = message.approval === 'denied'
return (
<QueryCell
key={message.id}
value={message.cell}
result={result}
rowLimit={CHAT_ROW_LIMIT}
readOnly
onRun={isPending ? undefined : () => onApprove(message.id, message.cell)}
footerSlot={
<>
{isPending && (
<div className="flex items-center gap-3 border-t bg-surface-200 px-3 py-2">
<p className="flex-1 text-xs text-foreground-light">
The Assistant wants to run this query.
</p>
<Button
variant="default"
size="tiny"
icon={<X size={12} />}
onClick={() => onDeny(message.id)}
>
Skip
</Button>
<Button
variant="primary"
size="tiny"
icon={<Check size={12} />}
onClick={() => onApprove(message.id, message.cell)}
>
Run query
</Button>
</div>
)}
{isDenied && (
<p className="border-t px-3 py-1 text-xs text-foreground-lighter">
Skipped — the query was not run.
</p>
)}
{result.status === 'success' && (
<p className="border-t px-3 py-1 text-xs text-foreground-lighter">
{canShareResults
? 'Results sent to the Assistant.'
: 'Results shown here only — data sharing is off, so rows are not sent to the Assistant.'}
</p>
)}
</>
}
/>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,70 @@
/**
* PROTOTYPE — markdown cell. Click to edit, blur to preview.
* Uses the shared CodeEditor in markdown mode and Studio's Markdown renderer.
*
* Like `QueryCell`, this owns no notebook chrome: reorder, delete and insert
* are rendered by `NotebookCellShell` in the gutter beside it.
*/
import { useState } from 'react'
import { cn } from 'ui'
import type { MarkdownCell } from '../ExplorerPrototype.types'
import { Markdown } from '@/components/interfaces/Markdown'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
interface MarkdownCellViewProps {
cell: MarkdownCell
onChange: (cell: MarkdownCell) => void
}
export const MarkdownCellView = ({ cell, onChange }: MarkdownCellViewProps) => {
const [isEditing, setIsEditing] = useState(false)
return (
<div className="rounded-md">
{isEditing ? (
<div className="overflow-hidden rounded-md border bg-surface-100">
<div className="h-40">
<CodeEditor
id={cell.id}
language="markdown"
value={cell.markdown}
hideLineNumbers
onInputChange={(next) => onChange({ ...cell, markdown: next ?? '' })}
options={{ scrollBeyondLastLine: false, padding: { top: 10, bottom: 10 } }}
/>
</div>
<div className="flex justify-end border-t px-2 py-1">
<button
type="button"
tabIndex={0}
className="text-xs text-foreground-light hover:text-foreground"
onClick={() => setIsEditing(false)}
>
Done
</button>
</div>
</div>
) : (
<button
type="button"
tabIndex={0}
onClick={() => setIsEditing(true)}
className={cn(
'w-full rounded-md border border-transparent px-3 py-2 text-left',
'hover:border-default hover:bg-surface-100'
)}
>
{cell.markdown.trim().length > 0 ? (
<Markdown content={cell.markdown} className="max-w-none" />
) : (
<span className="text-xs text-foreground-lighter">
Empty markdown cell — click to edit
</span>
)}
</button>
)}
</div>
)
}
@@ -0,0 +1,104 @@
/**
* PROTOTYPE — the notebook's wrapper around a cell.
*
* Reorder / delete / insert are properties of the *notebook*, not of the cell,
* so they live here in a left gutter rather than inside `QueryCell` or
* `MarkdownCellView`. That keeps the cell components identical on every surface
* (notebook, snippet, agent chat) and lets both cell kinds share one control
* layout. Because nothing is stacked below a cell any more, the vertical gap
* between cells can be tight.
*/
import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from 'lucide-react'
import type { PropsWithChildren } from 'react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
interface NotebookCellShellProps {
isFirst: boolean
isLast: boolean
/** Markdown stays at a readable measure; query cells fill the width. */
contained?: boolean
onMoveUp: () => void
onMoveDown: () => void
onRemove: () => void
onAddCell: (type: 'query' | 'markdown') => void
}
const controlClassName = 'size-6 px-0 text-foreground-muted hover:text-foreground'
export const NotebookCellShell = ({
isFirst,
isLast,
contained = false,
onMoveUp,
onMoveDown,
onRemove,
onAddCell,
children,
}: PropsWithChildren<NotebookCellShellProps>) => (
<div className="group/cell relative pl-8">
<div
className={cn(
'absolute left-0 top-0 flex w-8 flex-col items-center gap-px pt-1',
'opacity-0 transition-opacity group-hover/cell:opacity-100 focus-within:opacity-100'
)}
>
<span
aria-hidden
className="flex size-6 cursor-grab items-center justify-center text-foreground-muted"
>
<GripVertical size={14} />
</span>
<Button
variant="text"
size="tiny"
className={controlClassName}
aria-label="Move cell up"
disabled={isFirst}
icon={<ChevronUp size={14} />}
onClick={onMoveUp}
/>
<Button
variant="text"
size="tiny"
className={controlClassName}
aria-label="Move cell down"
disabled={isLast}
icon={<ChevronDown size={14} />}
onClick={onMoveDown}
/>
<Button
variant="text"
size="tiny"
className={controlClassName}
aria-label="Delete cell"
icon={<Trash2 size={14} />}
onClick={onRemove}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="text"
size="tiny"
className={controlClassName}
aria-label="Add cell below"
icon={<Plus size={14} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right">
<DropdownMenuItem onClick={() => onAddCell('query')}>Query cell</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAddCell('markdown')}>Markdown cell</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className={cn(contained && 'mx-auto w-full max-w-3xl')}>{children}</div>
</div>
)
@@ -0,0 +1,188 @@
/**
* PROTOTYPE — Notebook view.
*
* An ordered list of cells. Array position is both display order and execution
* order — there is no separate ordering field. Query cells are a thin binding
* around the shared QueryCell component (PR N8's shape).
*
* Width is deliberately mixed: query cells run the full width because results
* and charts want the room, while markdown stays at a readable measure.
*/
import { Play, Settings } from 'lucide-react'
import { useEffect, useRef } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import type {
CellResultState,
NotebookCell,
NotebookContent,
QueryCellModel,
RunMode,
} from '../ExplorerPrototype.types'
import { RESOURCE_ICON } from '../ExplorerResources'
import { QueryCell } from '../QueryCell'
import { TabToolbar } from '../TabToolbar'
import { resolveEffectiveRowLimit, resolveEffectiveRunMode } from '../useExplorerPrototypeState'
import { MarkdownCellView } from './MarkdownCellView'
import { NotebookCellShell } from './NotebookCellShell'
const ROW_LIMITS = [50, 100, 500, 1000]
/** Prose measure for markdown cells; query cells ignore this and fill the width. */
const PROSE_WIDTH = 'mx-auto w-full max-w-3xl'
interface NotebookViewProps {
title: string
notebook: NotebookContent
results: Record<string, CellResultState>
onCellChange: (cellId: string, cell: NotebookCell) => void
onAddCell: (type: 'query' | 'markdown', afterCellId?: string) => void
onRemoveCell: (cellId: string) => void
onMoveCell: (cellId: string, direction: -1 | 1) => void
onSettingsChange: (settings: NotebookContent['settings']) => void
onRunCell: (cell: QueryCellModel, rowLimit: number) => void
onRunAll: () => void
}
export const NotebookView = ({
title,
notebook,
results,
onCellChange,
onAddCell,
onRemoveCell,
onMoveCell,
onSettingsChange,
onRunCell,
onRunAll,
}: NotebookViewProps) => {
const hasAutoRun = useRef(false)
// `on_open` runs the notebook once when it is opened. Write-detected cells
// halt the sequence — that rule lives in the state module, shared with the Assistant.
useEffect(() => {
if (hasAutoRun.current) return
if (notebook.settings.run_mode !== 'on_open') return
hasAutoRun.current = true
onRunAll()
}, [notebook.settings.run_mode, onRunAll])
return (
<div className="flex h-full flex-col">
<TabToolbar
icon={RESOURCE_ICON.notebook}
title={title}
actions={
<>
<Button variant="default" size="tiny" icon={<Play size={14} />} onClick={onRunAll}>
Run all
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="text"
size="tiny"
className="w-7 px-0"
aria-label="Notebook settings"
icon={<Settings size={14} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Run mode</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={notebook.settings.run_mode}
onValueChange={(value) =>
onSettingsChange({ ...notebook.settings, run_mode: value as RunMode })
}
>
<DropdownMenuRadioItem value="manual">Manual</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="on_open">On open</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Default row limit</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={String(notebook.settings.default_row_limit)}
onValueChange={(value) =>
onSettingsChange({ ...notebook.settings, default_row_limit: Number(value) })
}
>
{ROW_LIMITS.map((limit) => (
<DropdownMenuRadioItem key={limit} value={String(limit)}>
{limit} rows
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-2 p-4">
{notebook.cells.map((cell, index) => (
<NotebookCellShell
key={cell.id}
isFirst={index === 0}
isLast={index === notebook.cells.length - 1}
contained={cell.type === 'markdown'}
onMoveUp={() => onMoveCell(cell.id, -1)}
onMoveDown={() => onMoveCell(cell.id, 1)}
onRemove={() => onRemoveCell(cell.id)}
onAddCell={(type) => onAddCell(type, cell.id)}
>
{cell.type === 'markdown' ? (
<MarkdownCellView cell={cell} onChange={(next) => onCellChange(cell.id, next)} />
) : (
<QueryCell
value={cell}
result={results[cell.id] ?? { status: 'idle' }}
rowLimit={resolveEffectiveRowLimit(cell, notebook.settings)}
footerSlot={
resolveEffectiveRunMode(cell, notebook.settings) === 'on_open' ? (
<p className="border-t px-3 py-1 text-xs text-foreground-lighter">
Runs automatically when the notebook opens
</p>
) : undefined
}
onChange={(next) => onCellChange(cell.id, next)}
onRun={() => onRunCell(cell, resolveEffectiveRowLimit(cell, notebook.settings))}
/>
)}
</NotebookCellShell>
))}
{notebook.cells.length === 0 && (
<div
className={`flex flex-col items-center gap-2 rounded-md border border-dashed py-10 ${PROSE_WIDTH}`}
>
<p className="text-sm">This notebook is empty</p>
<p className="text-xs text-foreground-light">
Add a query cell to run SQL against your database or logs.
</p>
<div className="mt-2 flex gap-2">
<Button variant="default" size="tiny" onClick={() => onAddCell('query')}>
Add query cell
</Button>
<Button variant="default" size="tiny" onClick={() => onAddCell('markdown')}>
Add markdown cell
</Button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,45 @@
/**
* PROTOTYPE — Snippet view.
*
* Nothing here knows how to run a query or draw a chart. It maps the snippet
* through the adapter, hands the resulting one-cell model to the shared
* QueryCell component, and maps edits back.
*
* A snippet is a single query cell, so it always spans the full width — there
* is no prose to keep at a readable measure.
*/
import type { CellResultState, SnippetDoc } from '../ExplorerPrototype.types'
import { RESOURCE_ICON } from '../ExplorerResources'
import { QueryCell } from '../QueryCell'
import { TabToolbar } from '../TabToolbar'
import { queryCellToSnippet, snippetToQueryCell } from './snippetAdapter'
interface SnippetViewProps {
snippet: SnippetDoc
result: CellResultState
onChange: (snippet: SnippetDoc) => void
onRun: (rowLimit: number) => void
}
const SNIPPET_ROW_LIMIT = 100
export const SnippetView = ({ snippet, result, onChange, onRun }: SnippetViewProps) => {
const cell = snippetToQueryCell(snippet)
return (
<div className="flex h-full flex-col">
<TabToolbar icon={RESOURCE_ICON.snippet} title={snippet.name} />
<div className="flex-1 overflow-y-auto p-4">
<QueryCell
value={cell}
result={result}
rowLimit={SNIPPET_ROW_LIMIT}
onChange={(next) => onChange(queryCellToSnippet(next, snippet))}
onRun={() => onRun(SNIPPET_ROW_LIMIT)}
/>
</div>
</div>
)
}
@@ -0,0 +1,39 @@
/**
* PROTOTYPE — the snippet adapter from the design diagram (PR E6).
*
* A snippet is a one-cell notebook. These two pure functions are the whole
* mapping, and they are what makes the Explorer snippet view able to render the
* same shared QueryCell component as notebook cells.
*
* Note what the adapter does with source: today a snippet's source is *derived*
* from its content type and is immutable. Mapping into a QueryCell makes source
* an explicit, editable field — so mapping back has to write the content type
* from the chosen source.
*/
import type { QueryCellModel, SnippetDoc } from '../ExplorerPrototype.types'
const DEFAULT_LOG_RANGE = { type: 'relative' as const, amount: 1, unit: 'hour' as const }
export const snippetToQueryCell = (snippet: SnippetDoc): QueryCellModel => ({
id: snippet.id,
type: 'query',
name: snippet.name,
query: {
type: 'inline',
source:
snippet.contentType === 'log_sql'
? { id: 'logs', parameters: { time_range: DEFAULT_LOG_RANGE } }
: { id: 'database', parameters: { identifier: 'primary' } },
sql: snippet.sql,
},
display: snippet.display,
})
export const queryCellToSnippet = (cell: QueryCellModel, base: SnippetDoc): SnippetDoc => ({
...base,
name: cell.name,
contentType: cell.query.source.id === 'logs' ? 'log_sql' : 'sql',
sql: cell.query.sql,
display: cell.display,
})
@@ -0,0 +1,35 @@
import { ExplorerPrototype } from '@/components/interfaces/ExplorerPrototype/ExplorerPrototype'
import { ExplorerPrototypeProvider } from '@/components/interfaces/ExplorerPrototype/ExplorerPrototypeContext'
import { ExplorerSidebarMenu } from '@/components/interfaces/ExplorerPrototype/ExplorerSidebar'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ProjectLayoutWithAuth } from '@/components/layouts/ProjectLayout'
import type { NextPageWithLayout } from '@/types'
/**
* Throwaway reference prototype for the Explorer / Notebooks initiative.
*
* Uses the same project chrome as the SQL editor (`DefaultLayout` →
* `ProjectLayoutWithAuth` with a resizable `productMenu`). It does not go
* through `EditorBaseLayout`, because that layout renders the existing
* snippet-only `EditorTabs`; the Explorer brings its own resource-typed tab
* strip, which is exactly the swap PR E3 makes behind the `explorer` flag.
*
* Mock data only — nothing reaches a project.
*/
const ExplorerPrototypePage: NextPageWithLayout = () => <ExplorerPrototype />
ExplorerPrototypePage.getLayout = (page) => (
<DefaultLayout>
<ExplorerPrototypeProvider>
<ProjectLayoutWithAuth
resizableSidebar
product="Explorer"
productMenu={<ExplorerSidebarMenu />}
>
{page}
</ProjectLayoutWithAuth>
</ExplorerPrototypeProvider>
</DefaultLayout>
)
export default ExplorerPrototypePage