Files
supabase/apps/studio/state/tabs.tsx
T
Saxon FletcherandClaude Opus 5 85db94762c feat: add routed assistant chat tabs to Explorer (#49031)
<img width="1695" height="1042" alt="image"
src="https://github.com/user-attachments/assets/b02e64ff-32ac-4aee-bda5-00b56f3a29e5"
/>


## Summary

- add routed Explorer chat pages backed by explicit assistant chat IDs
- wait for persisted assistant state before creating chats so cold-load
creation is not overwritten
- register chat tabs and keep tab labels, navigation, close behavior,
and missing-chat cleanup in sync
- create and branch Explorer chats without changing the assistant
sidebar selection
- support both Next.js and TanStack Router paths

This is PR 2 of 3 and is stacked on #48973. Review and merge #48973
first. The Explorer discovery, toolbar, and cross-surface entry points
follow in #49032. This PR focuses purely on setting up chat tab types,
routes and assistant conversation.

## To Test
- Create a new assistant chat via the assistant sidebar, send a message
etc
- Copy the chat id
- Visit /explorer/chat/[id]
- Verify chat shows up, you can send more messages, chat is synced
across tab and sidebar
- Close the tab

## Test plan

- `mise exec node@22 -- pnpm --dir apps/studio exec tsc --noEmit`
- focused Vitest suite: 4 files / 30 tests covering assistant hydration,
chat creation, routed chat rendering, and tab lifecycle
- ESLint on changed TypeScript files
- Prettier check on changed source files


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

* **New Features**
* Added Explorer chat pages with support for opening, selecting, and
branching chats.
* Added chat tabs, stable navigation, chat icons, and fallback behavior
when tabs are closed.
* Added chat creation that waits for assistant state to finish loading.
* **Bug Fixes**
  * Removed tabs for deleted or unavailable chats.
  * Improved editor tab navigation and history clearing behavior.
* **Tests**
* Added coverage for chat routing, tab management, chat creation, and
assistant-state loading.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:19:42 +10:00

718 lines
24 KiB
TypeScript

import { safeLocalStorage, useParams } from 'common'
import { partition } from 'lodash'
import { type NextRouter } from 'next/router'
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState,
type ComponentType,
} from 'react'
import { proxy, subscribe, useSnapshot } from 'valtio'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import type { EditorType } from '@/components/layouts/editors/EditorsLayout.hooks'
import type { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
export const editorEntityTypes = {
table: ['r', 'v', 'm', 'f', 'p'],
sql: ['sql'],
explorer: ['notebook', 'query', 'chat'],
}
export type TabType = ENTITY_TYPE | 'sql' | 'notebook' | 'query' | 'chat' | 'explorer-home'
/** Fixed id for Explorer's pinned, non-closable Home tab. */
export const EXPLORER_HOME_TAB_ID = 'explorer-home'
/** Tab descriptor for Explorer's Home tab — shared by its trigger and its page. */
export const EXPLORER_HOME_TAB: Tab = {
id: EXPLORER_HOME_TAB_ID,
type: 'explorer-home',
label: 'Home',
isPreview: false,
closable: false,
}
type CreateTabIdParams = {
r: { id: number }
v: { id: number }
m: { id: number }
f: { id: number }
p: { id: number }
sql: { id: string }
notebook: { id: string }
query: { id: string }
chat: { id: string }
schema: { schema: string }
view: never
function: never
new: never
'explorer-home': never
}
export interface Tab {
id: string
type: TabType
label?: string
metadata?: {
schema?: string
name?: string
tableId?: number
sqlId?: string
notebookId?: string
queryId?: string
chatId?: string
scrollTop?: number
/**
* For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`),
* so the tab can show the matching icon without re-fetching the snippet.
* Absent on tabs persisted before this field existed — treat absent as
* `'database'` and backfill from the loaded snippet (source is immutable, so
* it never goes stale once set).
*/
sqlSource?: SqlSnippetSource
}
isPreview?: boolean
/**
* Whether the tab can be closed by the user (close button, keyboard shortcut,
* or a bulk close action). Defaults to `true` — absent on every tab except a
* pinned default (e.g. Explorer's Home tab), which sets this `false`.
*/
closable?: boolean
createdAt?: Date
updatedAt?: Date
}
/** Copy shown in the confirmation dialog before a close is allowed to proceed. */
export interface TabCloseConfirmation {
title: string
description: string
}
/**
* Per-tab-type behavior and UI the tabs layout delegates to, so the layout
* stays agnostic of what any given tab kind means. A domain (e.g. the SQL
* editor) registers a handler for its tab type via `registerTabTypeHandler`;
* tabs of types without a handler close with no extra behavior and show no
* status indicator.
*/
export interface TabTypeHandler {
/**
* Cleanup to run when the user closes a tab of this type (e.g. discarding a
* SQL snippet's unsaved local edits). Runs after the tab has been removed.
*/
onClose?: (tab: Tab) => void
/**
* Whether closing these tabs needs user confirmation. Receives the whole set
* of this type being closed (e.g. a bulk "Close Others") so the handler owns
* the dialog copy, including wording it for one vs. many. Return the copy to
* confirm first; return null/undefined to close immediately.
*/
confirmClose?: (tabs: Tab[]) => TabCloseConfirmation | null | undefined
/**
* Optional component rendered inside the tab to show type-specific status
* (e.g. a VS Code-style unsaved-changes dot for a SQL snippet). Owning the
* component here keeps the layout agnostic of what "status" means per type and
* lets the domain drive its own reactivity. Rendered only when it has
* something to show; otherwise it should render nothing.
*/
StatusIndicator?: ComponentType<{ tab: Tab }>
}
const MAX_RECENT_ITEMS = 8
export interface RecentItem {
id: string
type: TabType
label: string
timestamp: number
metadata?: {
schema?: string
name?: string
tableId?: number
sqlId?: string
notebookId?: string
queryId?: string
chatId?: string
sqlSource?: SqlSnippetSource
}
}
const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items'
const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
function getSavedRecentItems(ref: string): RecentItem[] {
if (!ref) return []
const stored = safeLocalStorage.getItem(getRecentItemsStorageKey(ref))
try {
return JSON.parse(stored ?? '{"items": []}').items
} catch (error) {
return []
}
}
const createDefaultTabsState = () => ({
activeTab: null as string | null,
openTabs: [] as string[],
tabsMap: {} as Record<string, Tab>,
previewTabId: undefined as string | undefined,
recentItems: [],
})
const TABS_STORAGE_KEY = 'supabase_studio_tabs'
const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
function getSavedTabs(ref: string) {
if (!ref) return createDefaultTabsState()
const stored = safeLocalStorage.getItem(getTabsStorageKey(ref))
if (!stored) return createDefaultTabsState()
try {
const parsed = JSON.parse(stored ?? JSON.stringify(createDefaultTabsState())) as ReturnType<
typeof createDefaultTabsState
>
if (
!parsed.openTabs ||
!Array.isArray(parsed.openTabs) ||
!parsed.tabsMap ||
typeof parsed.tabsMap !== 'object'
) {
return createDefaultTabsState()
}
return parsed
} catch (error) {
return createDefaultTabsState()
}
}
const getRecentItemLabel = (tab: Pick<Tab, 'label' | 'metadata'>) =>
tab.label || tab.metadata?.name || 'Untitled'
const syncRecentItemWithTab = (item: RecentItem, tab: Pick<Tab, 'label' | 'metadata'>) => {
const nextLabel = getRecentItemLabel(tab)
item.label = nextLabel
item.metadata = {
...item.metadata,
...tab.metadata,
name: nextLabel,
}
}
export function createTabsState(projectRef: string) {
const recentItems = getSavedRecentItems(projectRef)
const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
// Per-type behavior/UI, kept outside the Valtio proxy so handler closures
// (which may capture non-serializable things like a React Query client or a
// React component) are never proxied or persisted.
const tabHandlers = new Map<TabType, TabTypeHandler>()
const store = proxy({
// RECENT ITEMS
recentItems,
addRecentItem: (tab: Tab) => {
// Check if an item with the same ID already exists
const existingItem = store.recentItems.find((item) => item.id === tab.id)
if (existingItem) {
// If it exists, update its timestamp
existingItem.timestamp = Date.now()
syncRecentItemWithTab(existingItem, tab)
return // Exit the function
}
// If it doesn't exist, create and add a new item
const recentItem: RecentItem = {
id: tab.id, // Set the ID
type: tab.type, // Set the type
label: getRecentItemLabel(tab), // Set the label or default to 'Untitled'
timestamp: Date.now(), // Set the current timestamp
metadata: tab.metadata, // Set the metadata
}
// Add the new recent item to the beginning of the list
store.recentItems.unshift(recentItem)
// Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => {
if (editorEntityTypes.table.includes(item.type)) return item
})
store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
},
clearRecentItems: () => {
store.recentItems = []
},
removeRecentItem: (itemId: string) => {
store.recentItems = store.recentItems.filter((item) => item.id !== itemId)
},
removeRecentItems: (itemIds: string[]) => {
store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id))
},
removeRecentItemsByType: (type: TabType) => {
store.recentItems = store.recentItems.filter((item) => item.type !== type)
},
getRecentItemsByType: (type: TabType) => {
return store.recentItems.filter((item) => item.type === type)
},
// TABS
activeTab,
openTabs,
tabsMap,
previewTabId,
hasTab: (id: string) => {
return !!store.tabsMap[id]
},
addTab: (tab: Tab) => {
// If tab exists and is active, don't do anything
if (store.tabsMap[tab.id] && store.activeTab === tab.id) {
return
}
// If tab exists but isn't active, just make it active
if (store.tabsMap[tab.id]) {
store.activeTab = tab.id
if (!tab.isPreview) store.addRecentItem(tab)
return
}
// If this tab should be permanent, add it normally
if (tab.isPreview === false) {
store.openTabs = [...store.openTabs, tab.id]
store.tabsMap[tab.id] = tab
store.activeTab = tab.id
// Add to recent items when creating permanent tab
store.addRecentItem(tab)
return
}
// Remove any existing preview tab
if (store.previewTabId) {
store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId)
delete store.tabsMap[store.previewTabId]
}
// Add new preview tab
store.tabsMap[tab.id] = { ...tab, isPreview: true }
store.openTabs = [...store.openTabs, tab.id]
store.previewTabId = tab.id
store.activeTab = tab.id
},
// Ensures a tab that's always present, first, and outside the draggable/
// closable set (e.g. Explorer's Home tab) exists in the store, without
// touching which tab is active. Safe to call from wherever the pinned
// tab's trigger renders, regardless of which page currently owns focus.
ensurePinnedTab: (tab: Tab) => {
if (store.tabsMap[tab.id]) return
store.tabsMap[tab.id] = tab
store.openTabs = [tab.id, ...store.openTabs]
},
// Ensures a pinned tab exists (see ensurePinnedTab) and marks it active,
// without recording it in Recent Items — it isn't content to revisit,
// just a fixed destination. Call this from the page it represents, on
// mount, mirroring how regular tabs call addTab from their own page.
activatePinnedTab: (tab: Tab) => {
store.ensurePinnedTab(tab)
store.activeTab = tab.id
},
updateTab: (
id: string,
updates: { label?: string; scrollTop?: number; sqlSource?: SqlSnippetSource }
) => {
const tab = store.tabsMap[id]
if (!tab) return
if ('label' in updates) {
tab.label = updates.label
// Keep the persisted name aligned with the visible label so browser titles
// and tab state recover cleanly after entity renames.
if (typeof updates.label === 'string' && tab.metadata) {
tab.metadata.name = updates.label
}
const recentItem = store.recentItems.find((item) => item.id === id)
if (recentItem) syncRecentItemWithTab(recentItem, tab)
}
if ('scrollTop' in updates && tab.metadata) {
tab.metadata.scrollTop = updates.scrollTop
}
// Backfill the immutable source onto a tab (and its recent item) that
// predates the field, so its icon resolves correctly once the snippet loads.
if (updates.sqlSource !== undefined) {
if (tab.metadata) tab.metadata.sqlSource = updates.sqlSource
else tab.metadata = { sqlSource: updates.sqlSource }
const recentItem = store.recentItems.find((item) => item.id === id)
if (recentItem) syncRecentItemWithTab(recentItem, tab)
}
},
// Function to remove a tab from the store
// this is used for removing tabs from the localstorage state
// for handling a manual tab removal with a close action, use handleTabClose()
removeTab: (id: string) => {
if (store.tabsMap[id]?.closable === false) return
const idx = store.openTabs.indexOf(id)
store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
delete store.tabsMap[id]
// Clear the preview tab reference if the removed tab was the preview tab,
// so it doesn't linger in (persisted) state pointing at a closed tab
if (store.previewTabId === id) {
store.previewTabId = undefined
}
// Update active tab if the removed tab was active
if (id === store.activeTab) {
store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null
}
},
// Function to remove multiple tabs from the store
// this is used for removing tabs from the localstorage state
// for handling a manual tab removal with a close action, use handleTabClose()
removeTabs: (ids: string[]) => {
if (!ids.length) return
ids.forEach((id) => store.removeTab(id))
},
reorderTabs: (oldIndex: number, newIndex: number) => {
const newOpenTabs = [...store.openTabs]
const [removedTab] = newOpenTabs.splice(oldIndex, 1)
newOpenTabs.splice(newIndex, 0, removedTab)
store.openTabs = newOpenTabs
},
makeTabActive: (tabId: string) => {
const tab = store.tabsMap[tabId]
if (!tab) return
store.activeTab = tab.id
},
makeTabPermanent: (tabId: string) => {
const tab = store.tabsMap[tabId]
if (tab?.isPreview) {
tab.isPreview = false
store.previewTabId = undefined
// Add to recent items when preview tab becomes permanent
store.addRecentItem(tab)
}
},
makeActiveTabPermanent: () => {
if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
store.makeTabPermanent(store.activeTab)
return true
}
return false
},
// TABS HANDLERS
handleTabNavigation: (id: string, router: NextRouter) => {
const tab = store.tabsMap[id]
if (!tab) return
store.activeTab = id
// Add to recent items when navigating to a non-preview, non-new tab.
// Pinned tabs (e.g. Explorer's Home) are a fixed destination, not content
// to revisit, so they're excluded regardless of preview state.
if (!tab.isPreview && tab.closable !== false) store.addRecentItem(tab)
switch (tab.type) {
case 'sql':
const schema = (router.query.schema as string) || 'public'
router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`)
break
case 'notebook':
router.push(`/project/${router.query.ref}/explorer/notebook/${tab.metadata?.notebookId}`)
break
case 'query':
router.push(`/project/${router.query.ref}/explorer/query/${tab.metadata?.queryId}`)
break
case 'chat':
router.push(`/project/${router.query.ref}/explorer/chat/${tab.metadata?.chatId}`)
break
case 'explorer-home':
router.push(`/project/${router.query.ref}/explorer`)
break
case 'r':
case 'v':
case 'm':
case 'f':
case 'p':
router.push(
buildTableEditorUrl({
projectRef: router.query.ref as string,
tableId: tab.metadata?.tableId!,
schema: tab.metadata?.schema,
})
)
break
}
},
// TAB TYPE HANDLER REGISTRY
//
// Lets a domain own what a tab of its type means — how it closes and what
// status it shows — without the layout having to know. Registered per tab
// type; returns an unregister function.
//
// Bumped on every (un)register so components that render per-type UI (the
// status indicator) re-render to pick up a handler registered after they
// first rendered — handlers register in an effect, which runs after the
// tabs first paint.
handlerRegistrationVersion: 0,
registerTabTypeHandler: (type: TabType, handler: TabTypeHandler) => {
tabHandlers.set(type, handler)
store.handlerRegistrationVersion++
return () => {
if (tabHandlers.get(type) === handler) {
tabHandlers.delete(type)
store.handlerRegistrationVersion++
}
}
},
// The status-indicator component registered for a tab type, if any. Read
// `handlerRegistrationVersion` alongside this in render to stay reactive to
// late registration.
getTabStatusIndicator: (type: TabType) => tabHandlers.get(type)?.StatusIndicator,
// The confirmation to show before closing the given tabs, or null if none
// need confirming. Tabs are grouped by type and each type's handler is asked
// about its own set (so it can word the copy for one vs. many); the first
// handler that asks to confirm wins. The store authors no copy itself — that
// stays a concern of the registering domain.
getCloseConfirmation: (ids: string[]): TabCloseConfirmation | null => {
const tabsByType = new Map<TabType, Tab[]>()
for (const id of ids) {
const tab = store.tabsMap[id]
if (!tab) continue
const group = tabsByType.get(tab.type)
if (group) group.push(tab)
else tabsByType.set(tab.type, [tab])
}
for (const [type, tabs] of tabsByType) {
const confirmation = tabHandlers.get(type)?.confirmClose?.(tabs)
if (confirmation) return confirmation
}
return null
},
// Close multiple tabs as an intentional user action, running each tab type's
// close handler afterwards. Distinct from `removeTabs`, the low-level store
// mutation used for re-keying (rename/move) and stale cleanup, which must
// NOT trigger discard behavior.
closeTabs: (ids: string[]) => {
const closedTabs = ids
.map((id) => store.tabsMap[id])
.filter((tab): tab is Tab => tab !== undefined && tab.closable !== false)
store.removeTabs(closedTabs.map((tab) => tab.id))
closedTabs.forEach((tab) => tabHandlers.get(tab.type)?.onClose?.(tab))
},
handleTabClose: ({
id,
router,
editor,
onClose,
onClearDashboardHistory,
}: {
id: string
router: NextRouter
editor?: EditorType
onClose?: (id: string) => void
onClearDashboardHistory: () => void
}) => {
const tabBeingClosed = store.tabsMap[id]
const editorTabIds = (
editor
? Object.values(store.tabsMap).filter((tab) =>
editorEntityTypes[editor]?.includes(tab.type)
)
: []
).map((tab) => tab.id)
const tabIndexBeingClosed = editorTabIds.indexOf(id)
const isLastTabBeingClosed = tabIndexBeingClosed === editorTabIds.length - 1
const nextTabId =
editorTabIds.length === 1
? undefined
: isLastTabBeingClosed
? editorTabIds[tabIndexBeingClosed - 1]
: editorTabIds[tabIndexBeingClosed + 1]
const { [id]: value, ...otherTabs } = store.tabsMap
store.tabsMap = otherTabs
if (tabBeingClosed) {
const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id)
store.openTabs = updatedOpenTabs
}
// Remove the preview tab if it matches the tab being closed
if (store.previewTabId === id) {
store.previewTabId = undefined
}
// [Joshen] Only navigate away if we're closing the tab that's currently in focus
if (store.activeTab === id || id === 'new') {
if (nextTabId) {
store.activeTab = nextTabId
store.handleTabNavigation(nextTabId, router)
} else {
onClearDashboardHistory()
// If no tabs of same type, go to the home of the current section
switch (tabBeingClosed?.type) {
case 'sql':
router.push(`/project/${router.query.ref}/sql`)
break
case 'notebook':
case 'query':
case 'chat':
router.push(`/project/${router.query.ref}/explorer`)
break
case 'r':
case 'v':
case 'm':
case 'f':
case 'p':
router.push(`/project/${router.query.ref}/editor`)
break
default:
router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
}
}
}
onClose?.(id)
// Run the tab type's registered close behavior (e.g. discard a SQL
// snippet's unsaved edits). `tabBeingClosed` is captured before removal.
if (tabBeingClosed) {
tabHandlers.get(tabBeingClosed.type)?.onClose?.(tabBeingClosed)
}
},
handleTabCloseAll: ({
editor,
router,
onClearDashboardHistory,
}: {
editor: 'sql' | 'table' | 'explorer'
router: NextRouter
onClearDashboardHistory: () => void
}) => {
const tabsToClose = store.openTabs.filter((id) => {
const tab = store.tabsMap[id]
return tab !== undefined && editorEntityTypes[editor].includes(tab.type)
})
store.closeTabs(tabsToClose)
onClearDashboardHistory()
const editorPath = editor === 'table' ? 'editor' : editor
router.push(`/project/${router.query.ref}/${editorPath}`)
},
handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: NextRouter) => {
// Make permanent if needed
const draggedTab = store.tabsMap[tabId]
if (draggedTab?.isPreview) {
store.makeTabPermanent(tabId)
}
// Reorder tabs
const newOpenTabs = [...store.openTabs]
newOpenTabs.splice(oldIndex, 1)
newOpenTabs.splice(newIndex, 0, tabId)
store.openTabs = newOpenTabs
store.activeTab = tabId
// Handle navigation
store.handleTabNavigation(tabId, router)
},
})
return store
}
export type TabsState = ReturnType<typeof createTabsState>
export const TabsStateContext = createContext<TabsState>(createTabsState(''))
export const TabsStateContextProvider = ({ children }: PropsWithChildren) => {
const { ref: projectRef } = useParams()
const [state, setState] = useState(createTabsState(projectRef ?? ''))
useEffect(() => {
if (typeof window !== 'undefined' && !!projectRef) {
setState(createTabsState(projectRef ?? ''))
}
}, [projectRef])
useEffect(() => {
if (typeof window !== 'undefined' && projectRef) {
return subscribe(state, () => {
safeLocalStorage.setItem(
getTabsStorageKey(projectRef),
JSON.stringify({
activeTab: state.activeTab,
openTabs: state.openTabs,
tabsMap: state.tabsMap,
previewTabId: state.previewTabId,
})
)
safeLocalStorage.setItem(
getRecentItemsStorageKey(projectRef),
JSON.stringify({
items: state.recentItems,
})
)
})
}
}, [projectRef, state])
return <TabsStateContext.Provider value={state}>{children}</TabsStateContext.Provider>
}
export const useTabsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
const state = useContext(TabsStateContext)
return useSnapshot(state, options)
}
export function createTabId<T extends TabType>(type: T, params: CreateTabIdParams[T]): string {
switch (type) {
case 'r':
return `r-${(params as CreateTabIdParams['r']).id}`
case 'v':
return `v-${(params as CreateTabIdParams['v']).id}`
case 'm':
return `m-${(params as CreateTabIdParams['m']).id}`
case 'f':
return `f-${(params as CreateTabIdParams['f']).id}`
case 'p':
return `p-${(params as CreateTabIdParams['p']).id}`
case 'sql':
return `sql-${(params as CreateTabIdParams['sql']).id}`
case 'notebook':
return `notebook-${(params as CreateTabIdParams['notebook']).id}`
case 'query':
return `query-${(params as CreateTabIdParams['query']).id}`
case 'chat':
return `chat-${(params as CreateTabIdParams['chat']).id}`
default:
return ''
}
}