-
-
: undefined}
- newTabButton={
}
- onTabChange={handleTabChange}
+
+ setSection(undefined)}
+ rootAction={}
/>
+ }
+ productMenu={
+
+
+ {section === undefined && }
+ {section === 'notebook' && }
+ {section === 'chat' && }
+
+
+ }
+ >
+
+
+
+
+
+
+ : undefined}
+ newTabButton={}
+ onTabChange={handleTabChange}
+ />
+
+
{children}
- {children}
-
-
+
+
)
}
diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx
index a8777fa202e..4f9d7439a95 100644
--- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx
+++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.test.tsx
@@ -36,6 +36,10 @@ vi.mock('@/components/interfaces/Explorer/hooks', () => ({
useCreateChat: () => ({ openChat: vi.fn() }),
}))
+vi.mock('./ExplorerProvider', () => ({
+ useExplorerDeleteItem: () => ({ confirmDelete: vi.fn() }),
+}))
+
vi.mock('@/state/ai-assistant-state', () => ({
useAiAssistantChatList: () => [
{ id: 'old-chat', name: 'Older investigation', updatedAt: new Date('2026-01-01') },
@@ -54,20 +58,20 @@ describe('ExplorerNavChats', () => {
it('filters support chats, safely sorts rehydrated chats, and marks the route active', () => {
customRender(
)
- const chatButtons = screen.getAllByRole('button')
- expect(chatButtons.map((button) => button.textContent)).toEqual([
+ const chatLinks = screen.getAllByRole('link')
+ expect(chatLinks.map((link) => link.textContent)).toEqual([
'Recent investigation',
'Older investigation',
'Rehydrated chat',
])
- expect(chatButtons[0]).toHaveClass('active')
+ expect(chatLinks[0]).toHaveClass('active')
expect(screen.queryByText('Support conversation')).not.toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox', { name: 'Search chats' }), {
target: { value: 'rehydrated' },
})
- expect(screen.getByRole('button')).toHaveTextContent('Rehydrated chat')
+ expect(screen.getByRole('link')).toHaveTextContent('Rehydrated chat')
expect(screen.queryByText('Recent investigation')).not.toBeInTheDocument()
})
})
diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx
index 5635f23d706..c2211b926d8 100644
--- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx
+++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx
@@ -1,11 +1,10 @@
import { useParams } from 'common'
-import { MessageSquare } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
-import { cn } from 'ui'
-import { ExplorerNavResourceWrapper, rowClassName } from './ExplorerLayout.constants'
-import { useCreateChat } from '@/components/interfaces/Explorer/hooks'
+import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants'
+import { ExplorerNavItem } from './ExplorerNavItem'
+import { useExplorerDeleteItem } from './ExplorerProvider'
import type { ChatSession } from '@/state/ai-assistant-state'
import { useAiAssistantChatList } from '@/state/ai-assistant-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
@@ -22,10 +21,10 @@ const getVisibleChats = (chats: ChatSession[], search: string): ChatSession[] =>
export const ExplorerNavChats = () => {
const [search, setSearch] = useState('')
const router = useRouter()
- const { id } = useParams()
- const { openChat } = useCreateChat()
+ const { id, ref } = useParams()
const chatList = useAiAssistantChatList()
const tabs = useTabsStateSnapshot()
+ const { onSelectDelete } = useExplorerDeleteItem()
const chats = getVisibleChats(chatList, search)
@@ -41,20 +40,17 @@ export const ExplorerNavChats = () => {
const isActive = router.pathname.includes('/explorer/chat/') && id === chat.id
return (
-
+ onSelectDelete={() =>
+ onSelectDelete({ id: chat.id, type: 'chat', name: chat.name })
+ }
+ />
)
})
)}
diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx
index c5c44e7cdb0..7937a35f1de 100644
--- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx
+++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx
@@ -13,7 +13,9 @@ import {
rowClassName,
} from './ExplorerLayout.constants'
import { formatRelativeTimeShort, getRecentlyUpdatedItems } from './ExplorerNavHome.utils'
-import { useCreateChat, useCreateQuery } from '@/components/interfaces/Explorer/hooks'
+import { ExplorerNavItem } from './ExplorerNavItem'
+import { useExplorerDeleteItem } from './ExplorerProvider'
+import { useCreateQuery } from '@/components/interfaces/Explorer/hooks'
import { useContentCountQuery } from '@/data/content/content-count-query'
import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-infinite-query'
import { useAiAssistantChatList } from '@/state/ai-assistant-state'
@@ -26,12 +28,12 @@ export const ExplorerNavHome = ({
onSelectSection: (section: ExplorerResourceType) => void
}) => {
const router = useRouter()
- const { ref } = useParams()
+ const { id, ref } = useParams()
const tabs = useTabsStateSnapshot()
const appStateSnapshot = useAppStateSnapshot()
- const { openChat } = useCreateChat()
const { createQuery } = useCreateQuery()
+ const { onSelectDelete } = useExplorerDeleteItem()
const { data: notebooksData } = useNotebooksInfiniteQuery({ projectRef: ref, limit: 100 })
const notebooks = notebooksData?.pages.flatMap((page) => page.content) ?? []
@@ -103,39 +105,34 @@ export const ExplorerNavHome = ({
Nothing edited yet
) : (
recentItems.map((item) => {
- const Icon = EXPLORER_SECTIONS.find((section) => section.type === item.type)?.icon
- const content = (
- <>
- {Icon &&
}
-
{item.label}
-
- {formatRelativeTimeShort(item.updatedAt)}
-
- >
- )
+ const isActive = id === item.id
- return item.type === 'chat' ? (
-
- ) : (
-
- tabs.makeTabPermanent(createTabId('notebook', { id: item.id }))
+ type={item.type}
+ href={href}
+ name={item.label}
+ isActive={isActive}
+ onDoubleClick={onDoubleClick}
+ onSelectDelete={() =>
+ onSelectDelete({ id: item.id, type: item.type, name: item.label })
}
- >
- {content}
-
+ description={formatRelativeTimeShort(item.updatedAt)}
+ />
)
})
)}
diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavItem.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavItem.tsx
new file mode 100644
index 00000000000..ffef717c140
--- /dev/null
+++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavItem.tsx
@@ -0,0 +1,57 @@
+import { MessageSquare, NotebookText, Trash } from 'lucide-react'
+import Link from 'next/link'
+import { type CSSProperties } from 'react'
+import { cn, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from 'ui'
+
+import { rowClassName } from './ExplorerLayout.constants'
+
+interface ExplorerNavItemProps {
+ href: string
+ type: 'notebook' | 'chat'
+ name: string
+ description?: string
+ isActive?: boolean
+ style?: CSSProperties
+ onDoubleClick: () => void
+ onSelectDelete: () => void
+}
+
+export const ExplorerNavItem = ({
+ href,
+ type,
+ name,
+ description,
+ isActive = false,
+ style,
+ onDoubleClick,
+ onSelectDelete,
+}: ExplorerNavItemProps) => {
+ return (
+
+
+
+ {type === 'notebook' ? (
+
+ ) : (
+
+ )}
+ {name}
+ {!!description && (
+ {description}
+ )}
+
+
+
+
+
+ Delete {type}
+
+
+
+ )
+}
diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx
index b9abcf2191d..fecfee4c9bd 100644
--- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx
+++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx
@@ -1,13 +1,12 @@
import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
-import { NotebookText } from 'lucide-react'
-import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
-import { cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
-import { ExplorerNavResourceWrapper, rowClassName } from './ExplorerLayout.constants'
+import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants'
+import { ExplorerNavItem } from './ExplorerNavItem'
+import { useExplorerDeleteItem } from './ExplorerProvider'
import {
InfiniteListDefault,
LoaderForIconMenuItems,
@@ -24,6 +23,7 @@ const NOTEBOOK_ROW_HEIGHT = 28
type NotebookListItemProps = RowComponentBaseProps
& {
projectRef: string | undefined
activeNotebookId: string | undefined
+ onSelectDelete: (item: { id: string; type: 'notebook' | 'chat'; name: string }) => void
}
const NotebookListItem = ({
@@ -31,26 +31,30 @@ const NotebookListItem = ({
style,
projectRef,
activeNotebookId,
+ onSelectDelete,
}: NotebookListItemProps) => {
const isActive = activeNotebookId === notebook.id
const tabs = useTabsStateSnapshot()
return (
- tabs.makeTabPermanent(createTabId('notebook', { id: notebook.id }))}
- >
-
- {notebook.name}
-
+ onSelectDelete={() =>
+ onSelectDelete({ id: notebook.id, type: 'notebook', name: notebook.name })
+ }
+ />
)
}
export const ExplorerNavNotebooks = () => {
const router = useRouter()
const { ref, id } = useParams()
+ const { onSelectDelete } = useExplorerDeleteItem()
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
@@ -74,7 +78,10 @@ export const ExplorerNavNotebooks = () => {
const activeNotebookId = router.pathname.includes('/explorer/notebook/') ? id : undefined
- const itemProps = useMemo(() => ({ projectRef: ref, activeNotebookId }), [ref, activeNotebookId])
+ const itemProps = useMemo(
+ () => ({ projectRef: ref, activeNotebookId, onSelectDelete }),
+ [ref, activeNotebookId, onSelectDelete]
+ )
return (
diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerProvider.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerProvider.tsx
new file mode 100644
index 00000000000..3fd0530c527
--- /dev/null
+++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerProvider.tsx
@@ -0,0 +1,96 @@
+import { useParams } from 'common'
+import { useRouter } from 'next/router'
+import { createContext, useContext, useState, type PropsWithChildren } from 'react'
+import { toast } from 'sonner'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
+
+import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
+import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
+import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
+import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
+import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
+
+type ExplorerDeleteItem = { id: string; type: 'notebook' | 'chat'; name: string }
+
+interface ExplorerContextValue {
+ onSelectDelete: (item: ExplorerDeleteItem) => void
+}
+
+const ExplorerContext = createContext(undefined)
+
+export const useExplorerDeleteItem = () => {
+ const context = useContext(ExplorerContext)
+ if (!context) {
+ throw new Error('useExplorerDeleteItem must be used within an ExplorerProvider')
+ }
+ return context
+}
+
+export const ExplorerProvider = ({ children }: PropsWithChildren) => {
+ const router = useRouter()
+ const { ref } = useParams()
+ const tabs = useTabsStateSnapshot()
+ const notebooksSnap = useNotebooksStateSnapshot()
+ const aiAssistantSnap = useAiAssistantStateSnapshot()
+ const { setLastVisitedExplorerTab } = useDashboardHistory()
+
+ const [pending, setPending] = useState(null)
+
+ const onClearDashboardHistory = () => setLastVisitedExplorerTab(undefined)
+
+ const closeTabForItem = (item: ExplorerDeleteItem) => {
+ tabs.handleTabClose({
+ id: createTabId(item.type, { id: item.id }),
+ router,
+ editor: 'explorer',
+ onClearDashboardHistory,
+ })
+ }
+
+ const { mutate: deleteNotebook, isPending: isDeletingNotebook } = useContentDeleteMutation({
+ onSuccess: () => {
+ if (!pending) return
+ toast.success('Successfully deleted notebook')
+ notebooksSnap.removeNotebook({ id: pending.id })
+ closeTabForItem(pending)
+ setPending(null)
+ },
+ onError: (error) => toast.error(`Failed to delete notebook: ${error.message}`),
+ })
+
+ const handleConfirm = () => {
+ if (!pending) return
+
+ if (pending.type === 'notebook') {
+ if (!ref) return
+ deleteNotebook({ projectRef: ref, ids: [pending.id] })
+ } else {
+ aiAssistantSnap.deleteChat(pending.id)
+ toast.success(`Deleted "${pending.name}"`)
+ closeTabForItem(pending)
+ setPending(null)
+ }
+ }
+
+ return (
+
+ {children}
+
+ setPending(null)}
+ onConfirm={handleConfirm}
+ >
+
+ This action cannot be undone. Are you sure you want to delete '{pending?.name}'?
+
+
+
+ )
+}